@emilia-protocol/gate 0.14.0 → 0.15.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/CHANGELOG.md +60 -1
- package/README.md +32 -1
- package/aeb-consumption-store.js +3 -0
- package/dist/aeb-consumption-store.d.ts +88 -0
- package/dist/aeb-consumption-store.d.ts.map +1 -0
- package/dist/aeb-consumption-store.js +471 -0
- package/dist/aeb-consumption-store.js.map +1 -0
- package/dist/capability-receipt.d.ts +8 -0
- package/dist/capability-receipt.d.ts.map +1 -1
- package/dist/capability-receipt.js +17 -0
- package/dist/capability-receipt.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts +144 -0
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -0
- package/dist/proposal-to-effect-postgres.js +1578 -0
- package/dist/proposal-to-effect-postgres.js.map +1 -0
- package/dist/proposal-to-effect-status-head-store.d.ts +72 -0
- package/dist/proposal-to-effect-status-head-store.d.ts.map +1 -0
- package/dist/proposal-to-effect-status-head-store.js +355 -0
- package/dist/proposal-to-effect-status-head-store.js.map +1 -0
- package/dist/proposal-to-effect-status.d.ts +55 -0
- package/dist/proposal-to-effect-status.d.ts.map +1 -0
- package/dist/proposal-to-effect-status.js +294 -0
- package/dist/proposal-to-effect-status.js.map +1 -0
- package/dist/proposal-to-effect.d.ts +151 -3
- package/dist/proposal-to-effect.d.ts.map +1 -1
- package/dist/proposal-to-effect.js +589 -46
- package/dist/proposal-to-effect.js.map +1 -1
- package/dist/remedy-case-set-postgres.d.ts +73 -0
- package/dist/remedy-case-set-postgres.d.ts.map +1 -0
- package/dist/remedy-case-set-postgres.js +846 -0
- package/dist/remedy-case-set-postgres.js.map +1 -0
- package/dist/remedy-case-set.d.ts +53 -0
- package/dist/remedy-case-set.d.ts.map +1 -0
- package/dist/remedy-case-set.js +571 -0
- package/dist/remedy-case-set.js.map +1 -0
- package/dist/remedy-program-adapters.d.ts +137 -0
- package/dist/remedy-program-adapters.d.ts.map +1 -0
- package/dist/remedy-program-adapters.js +590 -0
- package/dist/remedy-program-adapters.js.map +1 -0
- package/dist/trust-program-revocation.js.map +1 -1
- package/package.json +283 -63
- package/proposal-to-effect-postgres.js +3 -0
- package/proposal-to-effect-status-head-store.js +2 -0
- package/proposal-to-effect-status.js +2 -0
- package/remedy-case-set-postgres.js +3 -0
- package/remedy-case-set.js +3 -0
- package/remedy-program-adapters.js +3 -0
- package/src/aeb-consumption-store.ts +568 -0
- package/src/capability-receipt.ts +17 -0
- package/src/index.ts +66 -3
- package/src/proposal-to-effect-postgres.ts +1833 -0
- package/src/proposal-to-effect-status-head-store.ts +449 -0
- package/src/proposal-to-effect-status.ts +388 -0
- package/src/proposal-to-effect.ts +755 -50
- package/src/remedy-case-set-postgres.ts +1008 -0
- package/src/remedy-case-set.ts +603 -0
- package/src/remedy-program-adapters.ts +657 -0
- package/src/trust-program-revocation.ts +1 -1
|
@@ -0,0 +1,1833 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* PostgreSQL custody for Proposal-to-Effect consequence attempts.
|
|
4
|
+
*
|
|
5
|
+
* The application issues an opaque owner capability and sends only its keyed
|
|
6
|
+
* digest to PostgreSQL. The database owns state-transition atomicity, terminal
|
|
7
|
+
* immutability, and the exact attempt/provider-evidence join.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import type {
|
|
12
|
+
AuthenticatedProviderEvidenceBinding,
|
|
13
|
+
ConsequenceAttemptBinding,
|
|
14
|
+
ConsequenceAttemptOwnerHandle,
|
|
15
|
+
ConsequenceAttemptReference,
|
|
16
|
+
ConsequenceAttemptState,
|
|
17
|
+
ProposalToEffectConsequenceAttemptStore,
|
|
18
|
+
} from './proposal-to-effect.js';
|
|
19
|
+
|
|
20
|
+
type AebDigest = ConsequenceAttemptBinding['request_digest'];
|
|
21
|
+
type QueryRow = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$/;
|
|
24
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
25
|
+
const OWNER_PATTERN = /^pto-owner:v1:[A-Za-z0-9_-]{43}$/;
|
|
26
|
+
const OWNER_DOMAIN = 'EMILIA-PROPOSAL-TO-EFFECT-POSTGRES-v1:OWNER\0';
|
|
27
|
+
const ATTEMPT_DOMAIN = 'EMILIA-PROPOSAL-TO-EFFECT-POSTGRES-v1:ATTEMPT\0';
|
|
28
|
+
const EVIDENCE_DOMAIN = 'EMILIA-PROPOSAL-TO-EFFECT-POSTGRES-v1:EVIDENCE\0';
|
|
29
|
+
const OWNER_BYTES = 32;
|
|
30
|
+
const DEFAULT_LEASE_SECONDS = 30;
|
|
31
|
+
const MAX_LEASE_SECONDS = 300;
|
|
32
|
+
|
|
33
|
+
const STATES = new Set<ConsequenceAttemptState>([
|
|
34
|
+
'RESERVED',
|
|
35
|
+
'INVOKING',
|
|
36
|
+
'INDETERMINATE',
|
|
37
|
+
'COMMITTED',
|
|
38
|
+
'RELEASED',
|
|
39
|
+
'ESCALATED',
|
|
40
|
+
]);
|
|
41
|
+
const TERMINAL_STATES = new Set<ConsequenceAttemptState>([
|
|
42
|
+
'COMMITTED',
|
|
43
|
+
'RELEASED',
|
|
44
|
+
'ESCALATED',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export interface ProposalToEffectPostgresQueryResult {
|
|
48
|
+
rowCount: number | null;
|
|
49
|
+
rows: unknown[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ProposalToEffectPostgresClient {
|
|
53
|
+
query(
|
|
54
|
+
text: string,
|
|
55
|
+
params?: readonly unknown[],
|
|
56
|
+
): Promise<ProposalToEffectPostgresQueryResult>;
|
|
57
|
+
/** Passing an error discards an ambiguously committed pooled connection. */
|
|
58
|
+
release(error?: Error): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ProposalToEffectPostgresPool {
|
|
62
|
+
connect(): Promise<ProposalToEffectPostgresClient>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProposalToEffectAttemptDigests {
|
|
66
|
+
operation_digest: AebDigest;
|
|
67
|
+
action_digest: AebDigest;
|
|
68
|
+
config_digest: AebDigest;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProposalToEffectPostgresAttemptLookup {
|
|
72
|
+
tenant_id: string;
|
|
73
|
+
provider_id: string;
|
|
74
|
+
provider_account_id: string;
|
|
75
|
+
environment: string;
|
|
76
|
+
request_digest: AebDigest;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ProposalToEffectPostgresAttemptReference {
|
|
80
|
+
tenant_id: string;
|
|
81
|
+
provider_id: string;
|
|
82
|
+
provider_account_id: string;
|
|
83
|
+
environment: string;
|
|
84
|
+
attempt_id: string;
|
|
85
|
+
request_digest: AebDigest;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ProposalToEffectPostgresAttemptSnapshot
|
|
89
|
+
extends ProposalToEffectPostgresAttemptReference, ProposalToEffectAttemptDigests {
|
|
90
|
+
attempt_digest: AebDigest;
|
|
91
|
+
state: ConsequenceAttemptState;
|
|
92
|
+
evidence_digest: AebDigest | null;
|
|
93
|
+
last_heartbeat_at: string;
|
|
94
|
+
lease_expires_at: string;
|
|
95
|
+
lease_stale: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ProposalToEffectPostgresRecoveryAuthorization
|
|
99
|
+
extends ProposalToEffectPostgresAttemptSnapshot {
|
|
100
|
+
owner_generation: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ProposalToEffectPostgresRecoveryResult =
|
|
104
|
+
| {
|
|
105
|
+
recovered: true;
|
|
106
|
+
owner: ConsequenceAttemptOwnerHandle;
|
|
107
|
+
state: 'RESERVED' | 'INDETERMINATE';
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
recovered: false;
|
|
111
|
+
reason:
|
|
112
|
+
| 'attempt_not_found'
|
|
113
|
+
| 'attempt_not_stale'
|
|
114
|
+
| 'recovery_not_authorized'
|
|
115
|
+
| 'recovery_conflict'
|
|
116
|
+
| 'terminal_state_immutable';
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export interface ProposalToEffectPostgresStore
|
|
120
|
+
extends ProposalToEffectConsequenceAttemptStore {
|
|
121
|
+
/**
|
|
122
|
+
* Rediscover an attempt after a lost response using only the exact,
|
|
123
|
+
* server-derived provider tuple and request digest. This neither executes
|
|
124
|
+
* nor rotates custody and returns no owner or operational state.
|
|
125
|
+
*/
|
|
126
|
+
lookup(
|
|
127
|
+
input: ProposalToEffectPostgresAttemptLookup,
|
|
128
|
+
): Promise<ProposalToEffectPostgresAttemptReference | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Read operational saga state by its complete durable namespace and request
|
|
131
|
+
* digest. Owner material is deliberately absent.
|
|
132
|
+
*/
|
|
133
|
+
read(
|
|
134
|
+
input: ProposalToEffectPostgresAttemptReference,
|
|
135
|
+
): Promise<ProposalToEffectPostgresAttemptSnapshot | null>;
|
|
136
|
+
/**
|
|
137
|
+
* Renew nonterminal custody. The owner digest and database lease jointly
|
|
138
|
+
* fence stale workers; the opaque owner never crosses the SQL boundary.
|
|
139
|
+
*/
|
|
140
|
+
heartbeat(input: ConsequenceAttemptReference): Promise<boolean>;
|
|
141
|
+
/**
|
|
142
|
+
* Rotate ownership after restart only after the configured server callback
|
|
143
|
+
* authorizes the exact stored tenant/attempt/request binding. INVOKING is
|
|
144
|
+
* conservatively claimed as INDETERMINATE.
|
|
145
|
+
*/
|
|
146
|
+
recover(
|
|
147
|
+
input: ProposalToEffectPostgresAttemptReference,
|
|
148
|
+
): Promise<ProposalToEffectPostgresRecoveryResult>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface CreateProposalToEffectPostgresStoreOptions {
|
|
152
|
+
/** Least-privilege executor connection; it must not hold the recovery role. */
|
|
153
|
+
pool: ProposalToEffectPostgresPool;
|
|
154
|
+
/** Separately credentialed recovery connection; must differ from pool. */
|
|
155
|
+
recovery_pool: ProposalToEffectPostgresPool;
|
|
156
|
+
/** Server-held key copied at construction; minimum 256 bits. */
|
|
157
|
+
owner_hmac_sha256_key: Uint8Array;
|
|
158
|
+
/**
|
|
159
|
+
* Resolve the digests not carried by ConsequenceAttemptBinding from
|
|
160
|
+
* server-controlled canonical request custody.
|
|
161
|
+
*/
|
|
162
|
+
resolve_binding_digests(
|
|
163
|
+
binding: Readonly<ConsequenceAttemptBinding>,
|
|
164
|
+
): Promise<ProposalToEffectAttemptDigests> | ProposalToEffectAttemptDigests;
|
|
165
|
+
/**
|
|
166
|
+
* Explicit server authorization for restart ownership rotation. This
|
|
167
|
+
* callback never receives the old owner token or its keyed digest.
|
|
168
|
+
*/
|
|
169
|
+
authorize_recovery(
|
|
170
|
+
authorization: Readonly<ProposalToEffectPostgresRecoveryAuthorization>,
|
|
171
|
+
): Promise<boolean> | boolean;
|
|
172
|
+
/** Injectable only for deterministic tests; production defaults to crypto.randomBytes. */
|
|
173
|
+
random_bytes?: (size: number) => Uint8Array;
|
|
174
|
+
/** Database-enforced lease duration. Defaults to 30 seconds, max 5 minutes. */
|
|
175
|
+
lease_seconds?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Install under a dedicated non-login owner. Runtime roles should receive only
|
|
180
|
+
* schema USAGE plus EXECUTE on the RPCs they need; no table privilege is
|
|
181
|
+
* required or intended. Keep recover_attempt limited to the server role that
|
|
182
|
+
* runs authorize_recovery.
|
|
183
|
+
*/
|
|
184
|
+
export const PROPOSAL_TO_EFFECT_POSTGRES_DDL = String.raw`
|
|
185
|
+
CREATE SCHEMA IF NOT EXISTS proposal_to_effect_private;
|
|
186
|
+
REVOKE ALL ON SCHEMA proposal_to_effect_private FROM PUBLIC;
|
|
187
|
+
|
|
188
|
+
DO $roles$
|
|
189
|
+
BEGIN
|
|
190
|
+
IF NOT EXISTS (
|
|
191
|
+
SELECT 1 FROM pg_catalog.pg_roles
|
|
192
|
+
WHERE rolname = 'proposal_to_effect_executor'
|
|
193
|
+
) THEN
|
|
194
|
+
CREATE ROLE proposal_to_effect_executor NOLOGIN
|
|
195
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
196
|
+
END IF;
|
|
197
|
+
IF NOT EXISTS (
|
|
198
|
+
SELECT 1 FROM pg_catalog.pg_roles
|
|
199
|
+
WHERE rolname = 'proposal_to_effect_recovery'
|
|
200
|
+
) THEN
|
|
201
|
+
CREATE ROLE proposal_to_effect_recovery NOLOGIN
|
|
202
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
|
203
|
+
END IF;
|
|
204
|
+
END
|
|
205
|
+
$roles$;
|
|
206
|
+
|
|
207
|
+
CREATE TABLE IF NOT EXISTS proposal_to_effect_private.tenant_principals (
|
|
208
|
+
principal_name NAME NOT NULL,
|
|
209
|
+
tenant_id TEXT COLLATE "C" NOT NULL
|
|
210
|
+
CHECK (tenant_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
211
|
+
can_execute BOOLEAN NOT NULL DEFAULT FALSE,
|
|
212
|
+
can_recover BOOLEAN NOT NULL DEFAULT FALSE,
|
|
213
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT pg_catalog.clock_timestamp(),
|
|
214
|
+
PRIMARY KEY (principal_name, tenant_id),
|
|
215
|
+
CHECK (can_execute OR can_recover)
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
CREATE TABLE IF NOT EXISTS proposal_to_effect_private.consequence_attempts (
|
|
219
|
+
tenant_id TEXT COLLATE "C" NOT NULL
|
|
220
|
+
CHECK (tenant_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
221
|
+
provider_id TEXT COLLATE "C" NOT NULL
|
|
222
|
+
CHECK (provider_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
223
|
+
provider_account_id TEXT COLLATE "C" NOT NULL
|
|
224
|
+
CHECK (provider_account_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
225
|
+
environment TEXT COLLATE "C" NOT NULL
|
|
226
|
+
CHECK (environment ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
227
|
+
attempt_id TEXT COLLATE "C" NOT NULL
|
|
228
|
+
CHECK (attempt_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
229
|
+
operation_digest TEXT COLLATE "C" NOT NULL
|
|
230
|
+
CHECK (operation_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
231
|
+
request_digest TEXT COLLATE "C" NOT NULL
|
|
232
|
+
CHECK (request_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
233
|
+
action_digest TEXT COLLATE "C" NOT NULL
|
|
234
|
+
CHECK (action_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
235
|
+
config_digest TEXT COLLATE "C" NOT NULL
|
|
236
|
+
CHECK (config_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
237
|
+
attempt_digest TEXT COLLATE "C" NOT NULL
|
|
238
|
+
CHECK (attempt_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
239
|
+
owner_digest TEXT COLLATE "C" NOT NULL UNIQUE
|
|
240
|
+
CHECK (owner_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
241
|
+
owner_generation BIGINT NOT NULL DEFAULT 0 CHECK (owner_generation >= 0),
|
|
242
|
+
state TEXT COLLATE "C" NOT NULL DEFAULT 'RESERVED'
|
|
243
|
+
CHECK (state IN (
|
|
244
|
+
'RESERVED', 'INVOKING', 'INDETERMINATE',
|
|
245
|
+
'COMMITTED', 'RELEASED', 'ESCALATED'
|
|
246
|
+
)),
|
|
247
|
+
evidence_digest TEXT COLLATE "C"
|
|
248
|
+
CHECK (evidence_digest IS NULL OR evidence_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
249
|
+
evidence_binding_digest TEXT COLLATE "C"
|
|
250
|
+
CHECK (
|
|
251
|
+
evidence_binding_digest IS NULL
|
|
252
|
+
OR evidence_binding_digest ~ '^sha256:[a-f0-9]{64}$'
|
|
253
|
+
),
|
|
254
|
+
last_heartbeat_at TIMESTAMPTZ NOT NULL,
|
|
255
|
+
lease_expires_at TIMESTAMPTZ NOT NULL,
|
|
256
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
257
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
258
|
+
PRIMARY KEY (
|
|
259
|
+
tenant_id, provider_id, provider_account_id, environment, attempt_id
|
|
260
|
+
),
|
|
261
|
+
UNIQUE (
|
|
262
|
+
tenant_id, provider_id, provider_account_id, environment, operation_digest
|
|
263
|
+
),
|
|
264
|
+
UNIQUE (
|
|
265
|
+
tenant_id, provider_id, provider_account_id, environment, request_digest
|
|
266
|
+
),
|
|
267
|
+
UNIQUE (
|
|
268
|
+
tenant_id, provider_id, provider_account_id, environment, attempt_digest
|
|
269
|
+
),
|
|
270
|
+
UNIQUE (
|
|
271
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
272
|
+
attempt_id, attempt_digest
|
|
273
|
+
),
|
|
274
|
+
CHECK (
|
|
275
|
+
(evidence_digest IS NULL AND evidence_binding_digest IS NULL)
|
|
276
|
+
OR
|
|
277
|
+
(evidence_digest IS NOT NULL AND evidence_binding_digest IS NOT NULL)
|
|
278
|
+
),
|
|
279
|
+
CHECK (created_at <= updated_at),
|
|
280
|
+
CHECK (created_at <= last_heartbeat_at),
|
|
281
|
+
CHECK (last_heartbeat_at < lease_expires_at)
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
CREATE TABLE IF NOT EXISTS proposal_to_effect_private.provider_evidence (
|
|
285
|
+
tenant_id TEXT COLLATE "C" NOT NULL,
|
|
286
|
+
provider_id TEXT COLLATE "C" NOT NULL,
|
|
287
|
+
provider_account_id TEXT COLLATE "C" NOT NULL,
|
|
288
|
+
environment TEXT COLLATE "C" NOT NULL,
|
|
289
|
+
attempt_id TEXT COLLATE "C" NOT NULL,
|
|
290
|
+
attempt_digest TEXT COLLATE "C" NOT NULL
|
|
291
|
+
CHECK (attempt_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
292
|
+
operation_id TEXT COLLATE "C" NOT NULL
|
|
293
|
+
CHECK (operation_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
294
|
+
caid TEXT COLLATE "C" NOT NULL
|
|
295
|
+
CHECK (caid ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
296
|
+
action_digest TEXT COLLATE "C" NOT NULL
|
|
297
|
+
CHECK (action_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
298
|
+
evidence_id TEXT COLLATE "C" NOT NULL
|
|
299
|
+
CHECK (evidence_id ~ '^[A-Za-z0-9][A-Za-z0-9:_.@/-]{2,255}$'),
|
|
300
|
+
observed_at TIMESTAMPTZ NOT NULL,
|
|
301
|
+
outcome TEXT COLLATE "C" NOT NULL
|
|
302
|
+
CHECK (outcome IN ('COMMITTED', 'NOT_COMMITTED', 'ESCALATED')),
|
|
303
|
+
evidence_digest TEXT COLLATE "C" NOT NULL
|
|
304
|
+
CHECK (evidence_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
305
|
+
evidence_binding_digest TEXT COLLATE "C" NOT NULL
|
|
306
|
+
CHECK (evidence_binding_digest ~ '^sha256:[a-f0-9]{64}$'),
|
|
307
|
+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT pg_catalog.clock_timestamp(),
|
|
308
|
+
PRIMARY KEY (
|
|
309
|
+
tenant_id, provider_id, provider_account_id, environment, evidence_digest
|
|
310
|
+
),
|
|
311
|
+
UNIQUE (
|
|
312
|
+
tenant_id, provider_id, provider_account_id, environment, evidence_id
|
|
313
|
+
),
|
|
314
|
+
UNIQUE (
|
|
315
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
316
|
+
evidence_binding_digest
|
|
317
|
+
),
|
|
318
|
+
FOREIGN KEY (
|
|
319
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
320
|
+
attempt_id, attempt_digest
|
|
321
|
+
) REFERENCES proposal_to_effect_private.consequence_attempts (
|
|
322
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
323
|
+
attempt_id, attempt_digest
|
|
324
|
+
) ON DELETE RESTRICT
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
ALTER TABLE proposal_to_effect_private.tenant_principals
|
|
328
|
+
ENABLE ROW LEVEL SECURITY;
|
|
329
|
+
ALTER TABLE proposal_to_effect_private.tenant_principals
|
|
330
|
+
FORCE ROW LEVEL SECURITY;
|
|
331
|
+
ALTER TABLE proposal_to_effect_private.consequence_attempts
|
|
332
|
+
ENABLE ROW LEVEL SECURITY;
|
|
333
|
+
ALTER TABLE proposal_to_effect_private.consequence_attempts
|
|
334
|
+
FORCE ROW LEVEL SECURITY;
|
|
335
|
+
ALTER TABLE proposal_to_effect_private.provider_evidence
|
|
336
|
+
ENABLE ROW LEVEL SECURITY;
|
|
337
|
+
ALTER TABLE proposal_to_effect_private.provider_evidence
|
|
338
|
+
FORCE ROW LEVEL SECURITY;
|
|
339
|
+
|
|
340
|
+
DO $ddl$
|
|
341
|
+
BEGIN
|
|
342
|
+
IF NOT EXISTS (
|
|
343
|
+
SELECT 1
|
|
344
|
+
FROM pg_catalog.pg_policies
|
|
345
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
346
|
+
AND tablename = 'tenant_principals'
|
|
347
|
+
AND policyname = 'proposal_to_effect_principals_owner_only'
|
|
348
|
+
) THEN
|
|
349
|
+
CREATE POLICY proposal_to_effect_principals_owner_only
|
|
350
|
+
ON proposal_to_effect_private.tenant_principals
|
|
351
|
+
USING (
|
|
352
|
+
CURRENT_USER = (
|
|
353
|
+
SELECT tableowner
|
|
354
|
+
FROM pg_catalog.pg_tables
|
|
355
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
356
|
+
AND tablename = 'tenant_principals'
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
WITH CHECK (
|
|
360
|
+
CURRENT_USER = (
|
|
361
|
+
SELECT tableowner
|
|
362
|
+
FROM pg_catalog.pg_tables
|
|
363
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
364
|
+
AND tablename = 'tenant_principals'
|
|
365
|
+
)
|
|
366
|
+
);
|
|
367
|
+
END IF;
|
|
368
|
+
IF NOT EXISTS (
|
|
369
|
+
SELECT 1
|
|
370
|
+
FROM pg_catalog.pg_policies
|
|
371
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
372
|
+
AND tablename = 'consequence_attempts'
|
|
373
|
+
AND policyname = 'proposal_to_effect_owner_only'
|
|
374
|
+
) THEN
|
|
375
|
+
CREATE POLICY proposal_to_effect_owner_only
|
|
376
|
+
ON proposal_to_effect_private.consequence_attempts
|
|
377
|
+
USING (
|
|
378
|
+
CURRENT_USER = (
|
|
379
|
+
SELECT tableowner
|
|
380
|
+
FROM pg_catalog.pg_tables
|
|
381
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
382
|
+
AND tablename = 'consequence_attempts'
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
WITH CHECK (
|
|
386
|
+
CURRENT_USER = (
|
|
387
|
+
SELECT tableowner
|
|
388
|
+
FROM pg_catalog.pg_tables
|
|
389
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
390
|
+
AND tablename = 'consequence_attempts'
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
END IF;
|
|
394
|
+
IF NOT EXISTS (
|
|
395
|
+
SELECT 1
|
|
396
|
+
FROM pg_catalog.pg_policies
|
|
397
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
398
|
+
AND tablename = 'provider_evidence'
|
|
399
|
+
AND policyname = 'proposal_to_effect_evidence_owner_only'
|
|
400
|
+
) THEN
|
|
401
|
+
CREATE POLICY proposal_to_effect_evidence_owner_only
|
|
402
|
+
ON proposal_to_effect_private.provider_evidence
|
|
403
|
+
USING (
|
|
404
|
+
CURRENT_USER = (
|
|
405
|
+
SELECT tableowner
|
|
406
|
+
FROM pg_catalog.pg_tables
|
|
407
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
408
|
+
AND tablename = 'provider_evidence'
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
WITH CHECK (
|
|
412
|
+
CURRENT_USER = (
|
|
413
|
+
SELECT tableowner
|
|
414
|
+
FROM pg_catalog.pg_tables
|
|
415
|
+
WHERE schemaname = 'proposal_to_effect_private'
|
|
416
|
+
AND tablename = 'provider_evidence'
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
END IF;
|
|
420
|
+
END
|
|
421
|
+
$ddl$;
|
|
422
|
+
|
|
423
|
+
REVOKE ALL ON TABLE proposal_to_effect_private.tenant_principals FROM PUBLIC;
|
|
424
|
+
REVOKE ALL ON TABLE proposal_to_effect_private.consequence_attempts FROM PUBLIC;
|
|
425
|
+
REVOKE ALL ON TABLE proposal_to_effect_private.provider_evidence FROM PUBLIC;
|
|
426
|
+
|
|
427
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.assert_tenant_principal(
|
|
428
|
+
p_tenant_id TEXT,
|
|
429
|
+
p_recovery BOOLEAN
|
|
430
|
+
)
|
|
431
|
+
RETURNS VOID
|
|
432
|
+
LANGUAGE plpgsql
|
|
433
|
+
VOLATILE
|
|
434
|
+
SECURITY DEFINER
|
|
435
|
+
SET search_path = ''
|
|
436
|
+
AS $fn$
|
|
437
|
+
DECLARE
|
|
438
|
+
v_role_ok BOOLEAN;
|
|
439
|
+
v_binding_ok BOOLEAN;
|
|
440
|
+
BEGIN
|
|
441
|
+
v_role_ok := CASE
|
|
442
|
+
WHEN p_recovery IS TRUE THEN pg_catalog.pg_has_role(
|
|
443
|
+
SESSION_USER, 'proposal_to_effect_recovery', 'MEMBER'
|
|
444
|
+
)
|
|
445
|
+
WHEN p_recovery IS FALSE THEN pg_catalog.pg_has_role(
|
|
446
|
+
SESSION_USER, 'proposal_to_effect_executor', 'MEMBER'
|
|
447
|
+
)
|
|
448
|
+
ELSE pg_catalog.pg_has_role(
|
|
449
|
+
SESSION_USER, 'proposal_to_effect_executor', 'MEMBER'
|
|
450
|
+
) OR pg_catalog.pg_has_role(
|
|
451
|
+
SESSION_USER, 'proposal_to_effect_recovery', 'MEMBER'
|
|
452
|
+
)
|
|
453
|
+
END;
|
|
454
|
+
SELECT EXISTS (
|
|
455
|
+
SELECT 1
|
|
456
|
+
FROM proposal_to_effect_private.tenant_principals AS principals
|
|
457
|
+
WHERE principals.principal_name = SESSION_USER
|
|
458
|
+
AND principals.tenant_id = p_tenant_id
|
|
459
|
+
AND CASE
|
|
460
|
+
WHEN p_recovery IS TRUE THEN principals.can_recover
|
|
461
|
+
WHEN p_recovery IS FALSE THEN principals.can_execute
|
|
462
|
+
ELSE principals.can_execute OR principals.can_recover
|
|
463
|
+
END
|
|
464
|
+
) INTO v_binding_ok;
|
|
465
|
+
IF v_role_ok IS NOT TRUE OR v_binding_ok IS NOT TRUE THEN
|
|
466
|
+
RAISE EXCEPTION 'PTE_TENANT_PRINCIPAL_REFUSED'
|
|
467
|
+
USING ERRCODE = '42501';
|
|
468
|
+
END IF;
|
|
469
|
+
END
|
|
470
|
+
$fn$;
|
|
471
|
+
|
|
472
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.guard_attempt_mutation()
|
|
473
|
+
RETURNS trigger
|
|
474
|
+
LANGUAGE plpgsql
|
|
475
|
+
SET search_path = ''
|
|
476
|
+
AS $fn$
|
|
477
|
+
BEGIN
|
|
478
|
+
IF TG_OP = 'DELETE' THEN
|
|
479
|
+
RAISE EXCEPTION 'PTE_ATTEMPT_DELETE_REFUSED';
|
|
480
|
+
END IF;
|
|
481
|
+
IF OLD.state IN ('COMMITTED', 'RELEASED', 'ESCALATED') THEN
|
|
482
|
+
RAISE EXCEPTION 'PTE_TERMINAL_ATTEMPT_IMMUTABLE';
|
|
483
|
+
END IF;
|
|
484
|
+
IF ROW(
|
|
485
|
+
NEW.tenant_id, NEW.provider_id, NEW.provider_account_id, NEW.environment,
|
|
486
|
+
NEW.attempt_id, NEW.operation_digest, NEW.request_digest,
|
|
487
|
+
NEW.action_digest, NEW.config_digest, NEW.attempt_digest, NEW.created_at
|
|
488
|
+
) IS DISTINCT FROM ROW(
|
|
489
|
+
OLD.tenant_id, OLD.provider_id, OLD.provider_account_id, OLD.environment,
|
|
490
|
+
OLD.attempt_id, OLD.operation_digest, OLD.request_digest,
|
|
491
|
+
OLD.action_digest, OLD.config_digest, OLD.attempt_digest, OLD.created_at
|
|
492
|
+
) THEN
|
|
493
|
+
RAISE EXCEPTION 'PTE_ATTEMPT_BINDING_IMMUTABLE';
|
|
494
|
+
END IF;
|
|
495
|
+
IF NEW.updated_at < OLD.updated_at
|
|
496
|
+
OR NEW.last_heartbeat_at < OLD.last_heartbeat_at
|
|
497
|
+
OR NEW.lease_expires_at < OLD.lease_expires_at
|
|
498
|
+
OR NEW.last_heartbeat_at >= NEW.lease_expires_at THEN
|
|
499
|
+
RAISE EXCEPTION 'PTE_LEASE_REWIND_REFUSED';
|
|
500
|
+
END IF;
|
|
501
|
+
|
|
502
|
+
IF NEW.owner_generation = OLD.owner_generation THEN
|
|
503
|
+
IF NEW.owner_digest IS DISTINCT FROM OLD.owner_digest OR NOT (
|
|
504
|
+
(OLD.state = NEW.state)
|
|
505
|
+
OR
|
|
506
|
+
(OLD.state = 'RESERVED' AND NEW.state = 'INVOKING')
|
|
507
|
+
OR (OLD.state = 'INVOKING' AND NEW.state = 'INDETERMINATE')
|
|
508
|
+
OR (
|
|
509
|
+
OLD.state = 'INDETERMINATE'
|
|
510
|
+
AND NEW.state IN ('COMMITTED', 'RELEASED', 'ESCALATED')
|
|
511
|
+
)
|
|
512
|
+
) THEN
|
|
513
|
+
RAISE EXCEPTION 'PTE_ATTEMPT_TRANSITION_REFUSED';
|
|
514
|
+
END IF;
|
|
515
|
+
IF ROW(NEW.evidence_digest, NEW.evidence_binding_digest)
|
|
516
|
+
IS DISTINCT FROM ROW(OLD.evidence_digest, OLD.evidence_binding_digest)
|
|
517
|
+
AND NOT (
|
|
518
|
+
OLD.state = 'INDETERMINATE'
|
|
519
|
+
AND NEW.state IN ('COMMITTED', 'RELEASED', 'ESCALATED')
|
|
520
|
+
AND OLD.evidence_digest IS NULL
|
|
521
|
+
AND OLD.evidence_binding_digest IS NULL
|
|
522
|
+
AND NEW.evidence_digest IS NOT NULL
|
|
523
|
+
AND NEW.evidence_binding_digest IS NOT NULL
|
|
524
|
+
) THEN
|
|
525
|
+
RAISE EXCEPTION 'PTE_ATTEMPT_EVIDENCE_REBIND_REFUSED';
|
|
526
|
+
END IF;
|
|
527
|
+
ELSIF NEW.owner_generation = OLD.owner_generation + 1 THEN
|
|
528
|
+
IF NEW.owner_digest IS NOT DISTINCT FROM OLD.owner_digest
|
|
529
|
+
OR ROW(NEW.evidence_digest, NEW.evidence_binding_digest)
|
|
530
|
+
IS DISTINCT FROM ROW(OLD.evidence_digest, OLD.evidence_binding_digest)
|
|
531
|
+
OR NOT (
|
|
532
|
+
(OLD.state = 'RESERVED' AND NEW.state = 'RESERVED')
|
|
533
|
+
OR (OLD.state = 'INVOKING' AND NEW.state = 'INDETERMINATE')
|
|
534
|
+
OR (OLD.state = 'INDETERMINATE' AND NEW.state = 'INDETERMINATE')
|
|
535
|
+
) THEN
|
|
536
|
+
RAISE EXCEPTION 'PTE_ATTEMPT_RECOVERY_REFUSED';
|
|
537
|
+
END IF;
|
|
538
|
+
ELSE
|
|
539
|
+
RAISE EXCEPTION 'PTE_OWNER_GENERATION_REFUSED';
|
|
540
|
+
END IF;
|
|
541
|
+
RETURN NEW;
|
|
542
|
+
END
|
|
543
|
+
$fn$;
|
|
544
|
+
|
|
545
|
+
DROP TRIGGER IF EXISTS proposal_to_effect_attempt_guard
|
|
546
|
+
ON proposal_to_effect_private.consequence_attempts;
|
|
547
|
+
CREATE TRIGGER proposal_to_effect_attempt_guard
|
|
548
|
+
BEFORE UPDATE OR DELETE ON proposal_to_effect_private.consequence_attempts
|
|
549
|
+
FOR EACH ROW EXECUTE FUNCTION proposal_to_effect_private.guard_attempt_mutation();
|
|
550
|
+
|
|
551
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.guard_evidence_mutation()
|
|
552
|
+
RETURNS trigger
|
|
553
|
+
LANGUAGE plpgsql
|
|
554
|
+
SET search_path = ''
|
|
555
|
+
AS $fn$
|
|
556
|
+
BEGIN
|
|
557
|
+
RAISE EXCEPTION 'PTE_PROVIDER_EVIDENCE_IMMUTABLE';
|
|
558
|
+
END
|
|
559
|
+
$fn$;
|
|
560
|
+
|
|
561
|
+
DROP TRIGGER IF EXISTS proposal_to_effect_evidence_guard
|
|
562
|
+
ON proposal_to_effect_private.provider_evidence;
|
|
563
|
+
CREATE TRIGGER proposal_to_effect_evidence_guard
|
|
564
|
+
BEFORE UPDATE OR DELETE ON proposal_to_effect_private.provider_evidence
|
|
565
|
+
FOR EACH ROW EXECUTE FUNCTION proposal_to_effect_private.guard_evidence_mutation();
|
|
566
|
+
|
|
567
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.reserve_attempt(
|
|
568
|
+
p_tenant_id TEXT,
|
|
569
|
+
p_provider_id TEXT,
|
|
570
|
+
p_provider_account_id TEXT,
|
|
571
|
+
p_environment TEXT,
|
|
572
|
+
p_attempt_id TEXT,
|
|
573
|
+
p_operation_digest TEXT,
|
|
574
|
+
p_request_digest TEXT,
|
|
575
|
+
p_action_digest TEXT,
|
|
576
|
+
p_config_digest TEXT,
|
|
577
|
+
p_attempt_digest TEXT,
|
|
578
|
+
p_owner_digest TEXT,
|
|
579
|
+
p_lease_seconds INTEGER
|
|
580
|
+
)
|
|
581
|
+
RETURNS TABLE(applied BOOLEAN, reason TEXT)
|
|
582
|
+
LANGUAGE plpgsql
|
|
583
|
+
SECURITY DEFINER
|
|
584
|
+
SET search_path = ''
|
|
585
|
+
AS $fn$
|
|
586
|
+
DECLARE
|
|
587
|
+
v_count BIGINT;
|
|
588
|
+
v_now TIMESTAMPTZ;
|
|
589
|
+
BEGIN
|
|
590
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
591
|
+
IF p_lease_seconds < 1 OR p_lease_seconds > 300 THEN
|
|
592
|
+
RAISE EXCEPTION 'PTE_LEASE_DURATION_REFUSED'
|
|
593
|
+
USING ERRCODE = '22023';
|
|
594
|
+
END IF;
|
|
595
|
+
v_now := pg_catalog.clock_timestamp();
|
|
596
|
+
INSERT INTO proposal_to_effect_private.consequence_attempts (
|
|
597
|
+
tenant_id, provider_id, provider_account_id, environment, attempt_id,
|
|
598
|
+
operation_digest, request_digest, action_digest, config_digest,
|
|
599
|
+
attempt_digest, owner_digest, last_heartbeat_at, lease_expires_at,
|
|
600
|
+
created_at, updated_at
|
|
601
|
+
) VALUES (
|
|
602
|
+
p_tenant_id, p_provider_id, p_provider_account_id, p_environment, p_attempt_id,
|
|
603
|
+
p_operation_digest, p_request_digest, p_action_digest, p_config_digest,
|
|
604
|
+
p_attempt_digest, p_owner_digest, v_now,
|
|
605
|
+
v_now + pg_catalog.make_interval(secs => p_lease_seconds),
|
|
606
|
+
v_now, v_now
|
|
607
|
+
)
|
|
608
|
+
ON CONFLICT DO NOTHING;
|
|
609
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
610
|
+
IF v_count = 1 THEN
|
|
611
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
612
|
+
ELSIF EXISTS (
|
|
613
|
+
SELECT 1
|
|
614
|
+
FROM proposal_to_effect_private.consequence_attempts
|
|
615
|
+
WHERE tenant_id = p_tenant_id
|
|
616
|
+
AND provider_id = p_provider_id
|
|
617
|
+
AND provider_account_id = p_provider_account_id
|
|
618
|
+
AND environment = p_environment
|
|
619
|
+
AND attempt_id = p_attempt_id
|
|
620
|
+
AND operation_digest = p_operation_digest
|
|
621
|
+
AND request_digest = p_request_digest
|
|
622
|
+
AND action_digest = p_action_digest
|
|
623
|
+
AND config_digest = p_config_digest
|
|
624
|
+
AND attempt_digest = p_attempt_digest
|
|
625
|
+
AND owner_digest = p_owner_digest
|
|
626
|
+
AND owner_generation = 0
|
|
627
|
+
AND state = 'RESERVED'
|
|
628
|
+
) THEN
|
|
629
|
+
-- idempotent_reservation: the original COMMIT may have succeeded.
|
|
630
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
631
|
+
ELSIF EXISTS (
|
|
632
|
+
SELECT 1
|
|
633
|
+
FROM proposal_to_effect_private.consequence_attempts
|
|
634
|
+
WHERE tenant_id = p_tenant_id
|
|
635
|
+
AND provider_id = p_provider_id
|
|
636
|
+
AND provider_account_id = p_provider_account_id
|
|
637
|
+
AND environment = p_environment
|
|
638
|
+
AND attempt_id = p_attempt_id
|
|
639
|
+
) THEN
|
|
640
|
+
RETURN QUERY SELECT FALSE, 'attempt_exists'::TEXT;
|
|
641
|
+
ELSE
|
|
642
|
+
RETURN QUERY SELECT FALSE, 'binding_conflict'::TEXT;
|
|
643
|
+
END IF;
|
|
644
|
+
END
|
|
645
|
+
$fn$;
|
|
646
|
+
|
|
647
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.transition_attempt(
|
|
648
|
+
p_tenant_id TEXT,
|
|
649
|
+
p_attempt_id TEXT,
|
|
650
|
+
p_owner_digest TEXT,
|
|
651
|
+
p_expected_state TEXT,
|
|
652
|
+
p_next_state TEXT,
|
|
653
|
+
p_lease_seconds INTEGER
|
|
654
|
+
)
|
|
655
|
+
RETURNS TABLE(applied BOOLEAN, reason TEXT)
|
|
656
|
+
LANGUAGE plpgsql
|
|
657
|
+
SECURITY DEFINER
|
|
658
|
+
SET search_path = ''
|
|
659
|
+
AS $fn$
|
|
660
|
+
DECLARE
|
|
661
|
+
v_count BIGINT;
|
|
662
|
+
v_now TIMESTAMPTZ;
|
|
663
|
+
BEGIN
|
|
664
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
665
|
+
IF p_lease_seconds < 1 OR p_lease_seconds > 300 THEN
|
|
666
|
+
RAISE EXCEPTION 'PTE_LEASE_DURATION_REFUSED'
|
|
667
|
+
USING ERRCODE = '22023';
|
|
668
|
+
END IF;
|
|
669
|
+
v_now := pg_catalog.clock_timestamp();
|
|
670
|
+
UPDATE proposal_to_effect_private.consequence_attempts
|
|
671
|
+
SET state = p_next_state,
|
|
672
|
+
last_heartbeat_at = v_now,
|
|
673
|
+
lease_expires_at = v_now + pg_catalog.make_interval(secs => p_lease_seconds),
|
|
674
|
+
updated_at = v_now
|
|
675
|
+
WHERE tenant_id = p_tenant_id
|
|
676
|
+
AND attempt_id = p_attempt_id
|
|
677
|
+
AND owner_digest = p_owner_digest
|
|
678
|
+
AND state = p_expected_state
|
|
679
|
+
AND (
|
|
680
|
+
(p_expected_state = 'RESERVED' AND p_next_state = 'INVOKING')
|
|
681
|
+
OR (p_expected_state = 'INVOKING' AND p_next_state = 'INDETERMINATE')
|
|
682
|
+
OR (
|
|
683
|
+
p_expected_state = 'INDETERMINATE'
|
|
684
|
+
AND p_next_state IN ('COMMITTED', 'RELEASED', 'ESCALATED')
|
|
685
|
+
)
|
|
686
|
+
);
|
|
687
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
688
|
+
IF v_count = 1 THEN
|
|
689
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
690
|
+
ELSIF EXISTS (
|
|
691
|
+
SELECT 1
|
|
692
|
+
FROM proposal_to_effect_private.consequence_attempts
|
|
693
|
+
WHERE tenant_id = p_tenant_id
|
|
694
|
+
AND attempt_id = p_attempt_id
|
|
695
|
+
AND owner_digest = p_owner_digest
|
|
696
|
+
AND state = p_next_state
|
|
697
|
+
) THEN
|
|
698
|
+
-- idempotent_transition: retry after an ambiguous COMMIT.
|
|
699
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
700
|
+
ELSE
|
|
701
|
+
RETURN QUERY SELECT FALSE, NULL::TEXT;
|
|
702
|
+
END IF;
|
|
703
|
+
END
|
|
704
|
+
$fn$;
|
|
705
|
+
|
|
706
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.heartbeat_attempt(
|
|
707
|
+
p_tenant_id TEXT,
|
|
708
|
+
p_attempt_id TEXT,
|
|
709
|
+
p_owner_digest TEXT,
|
|
710
|
+
p_lease_seconds INTEGER
|
|
711
|
+
)
|
|
712
|
+
RETURNS TABLE(applied BOOLEAN, reason TEXT)
|
|
713
|
+
LANGUAGE plpgsql
|
|
714
|
+
SECURITY DEFINER
|
|
715
|
+
SET search_path = ''
|
|
716
|
+
AS $fn$
|
|
717
|
+
DECLARE
|
|
718
|
+
v_count BIGINT;
|
|
719
|
+
v_now TIMESTAMPTZ;
|
|
720
|
+
BEGIN
|
|
721
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
722
|
+
IF p_lease_seconds < 1 OR p_lease_seconds > 300 THEN
|
|
723
|
+
RAISE EXCEPTION 'PTE_LEASE_DURATION_REFUSED'
|
|
724
|
+
USING ERRCODE = '22023';
|
|
725
|
+
END IF;
|
|
726
|
+
v_now := pg_catalog.clock_timestamp();
|
|
727
|
+
UPDATE proposal_to_effect_private.consequence_attempts
|
|
728
|
+
SET last_heartbeat_at = v_now,
|
|
729
|
+
lease_expires_at = v_now + pg_catalog.make_interval(secs => p_lease_seconds),
|
|
730
|
+
updated_at = v_now
|
|
731
|
+
WHERE tenant_id = p_tenant_id
|
|
732
|
+
AND attempt_id = p_attempt_id
|
|
733
|
+
AND owner_digest = p_owner_digest
|
|
734
|
+
AND state IN ('RESERVED', 'INVOKING', 'INDETERMINATE');
|
|
735
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
736
|
+
RETURN QUERY SELECT v_count = 1, NULL::TEXT;
|
|
737
|
+
END
|
|
738
|
+
$fn$;
|
|
739
|
+
|
|
740
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.reconcile_attempt(
|
|
741
|
+
p_tenant_id TEXT,
|
|
742
|
+
p_provider_id TEXT,
|
|
743
|
+
p_provider_account_id TEXT,
|
|
744
|
+
p_environment TEXT,
|
|
745
|
+
p_attempt_id TEXT,
|
|
746
|
+
p_owner_digest TEXT,
|
|
747
|
+
p_operation_digest TEXT,
|
|
748
|
+
p_request_digest TEXT,
|
|
749
|
+
p_action_digest TEXT,
|
|
750
|
+
p_config_digest TEXT,
|
|
751
|
+
p_attempt_digest TEXT,
|
|
752
|
+
p_operation_id TEXT,
|
|
753
|
+
p_caid TEXT,
|
|
754
|
+
p_next_state TEXT,
|
|
755
|
+
p_evidence_id TEXT,
|
|
756
|
+
p_observed_at TIMESTAMPTZ,
|
|
757
|
+
p_outcome TEXT,
|
|
758
|
+
p_evidence_digest TEXT,
|
|
759
|
+
p_evidence_binding_digest TEXT
|
|
760
|
+
)
|
|
761
|
+
RETURNS TABLE(applied BOOLEAN, reason TEXT)
|
|
762
|
+
LANGUAGE plpgsql
|
|
763
|
+
SECURITY DEFINER
|
|
764
|
+
SET search_path = ''
|
|
765
|
+
AS $fn$
|
|
766
|
+
DECLARE
|
|
767
|
+
v_attempt_digest TEXT;
|
|
768
|
+
BEGIN
|
|
769
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, FALSE);
|
|
770
|
+
IF NOT (
|
|
771
|
+
(p_outcome = 'COMMITTED' AND p_next_state = 'COMMITTED')
|
|
772
|
+
OR (p_outcome = 'NOT_COMMITTED' AND p_next_state = 'RELEASED')
|
|
773
|
+
OR (p_outcome = 'ESCALATED' AND p_next_state = 'ESCALATED')
|
|
774
|
+
) THEN
|
|
775
|
+
RETURN QUERY SELECT FALSE, NULL::TEXT;
|
|
776
|
+
RETURN;
|
|
777
|
+
END IF;
|
|
778
|
+
BEGIN
|
|
779
|
+
UPDATE proposal_to_effect_private.consequence_attempts
|
|
780
|
+
SET state = p_next_state,
|
|
781
|
+
evidence_digest = p_evidence_digest,
|
|
782
|
+
evidence_binding_digest = p_evidence_binding_digest,
|
|
783
|
+
updated_at = pg_catalog.clock_timestamp()
|
|
784
|
+
WHERE tenant_id = p_tenant_id
|
|
785
|
+
AND provider_id = p_provider_id
|
|
786
|
+
AND provider_account_id = p_provider_account_id
|
|
787
|
+
AND environment = p_environment
|
|
788
|
+
AND attempt_id = p_attempt_id
|
|
789
|
+
AND owner_digest = p_owner_digest
|
|
790
|
+
AND operation_digest = p_operation_digest
|
|
791
|
+
AND request_digest = p_request_digest
|
|
792
|
+
AND action_digest = p_action_digest
|
|
793
|
+
AND config_digest = p_config_digest
|
|
794
|
+
AND attempt_digest = p_attempt_digest
|
|
795
|
+
AND state = 'INDETERMINATE'
|
|
796
|
+
RETURNING attempt_digest INTO v_attempt_digest;
|
|
797
|
+
IF v_attempt_digest IS NOT NULL THEN
|
|
798
|
+
INSERT INTO proposal_to_effect_private.provider_evidence (
|
|
799
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
800
|
+
attempt_id, attempt_digest, operation_id, caid, action_digest,
|
|
801
|
+
evidence_id, observed_at, outcome,
|
|
802
|
+
evidence_digest, evidence_binding_digest
|
|
803
|
+
) VALUES (
|
|
804
|
+
p_tenant_id, p_provider_id, p_provider_account_id, p_environment,
|
|
805
|
+
p_attempt_id, p_attempt_digest, p_operation_id, p_caid, p_action_digest,
|
|
806
|
+
p_evidence_id, p_observed_at, p_outcome,
|
|
807
|
+
p_evidence_digest, p_evidence_binding_digest
|
|
808
|
+
);
|
|
809
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
810
|
+
RETURN;
|
|
811
|
+
END IF;
|
|
812
|
+
EXCEPTION
|
|
813
|
+
WHEN unique_violation OR foreign_key_violation OR check_violation THEN
|
|
814
|
+
v_attempt_digest := NULL;
|
|
815
|
+
END;
|
|
816
|
+
IF EXISTS (
|
|
817
|
+
SELECT 1
|
|
818
|
+
FROM proposal_to_effect_private.consequence_attempts AS attempts
|
|
819
|
+
JOIN proposal_to_effect_private.provider_evidence AS evidence
|
|
820
|
+
USING (
|
|
821
|
+
tenant_id, provider_id, provider_account_id, environment,
|
|
822
|
+
attempt_id, attempt_digest
|
|
823
|
+
)
|
|
824
|
+
WHERE attempts.tenant_id = p_tenant_id
|
|
825
|
+
AND attempts.provider_id = p_provider_id
|
|
826
|
+
AND attempts.provider_account_id = p_provider_account_id
|
|
827
|
+
AND attempts.environment = p_environment
|
|
828
|
+
AND attempts.attempt_id = p_attempt_id
|
|
829
|
+
AND attempts.owner_digest = p_owner_digest
|
|
830
|
+
AND attempts.operation_digest = p_operation_digest
|
|
831
|
+
AND attempts.request_digest = p_request_digest
|
|
832
|
+
AND attempts.action_digest = p_action_digest
|
|
833
|
+
AND attempts.config_digest = p_config_digest
|
|
834
|
+
AND attempts.attempt_digest = p_attempt_digest
|
|
835
|
+
AND attempts.state = p_next_state
|
|
836
|
+
AND attempts.evidence_digest = p_evidence_digest
|
|
837
|
+
AND attempts.evidence_binding_digest = p_evidence_binding_digest
|
|
838
|
+
AND evidence.operation_id = p_operation_id
|
|
839
|
+
AND evidence.caid = p_caid
|
|
840
|
+
AND evidence.action_digest = p_action_digest
|
|
841
|
+
AND evidence.evidence_id = p_evidence_id
|
|
842
|
+
AND evidence.observed_at = p_observed_at
|
|
843
|
+
AND evidence.outcome = p_outcome
|
|
844
|
+
AND evidence.evidence_digest = p_evidence_digest
|
|
845
|
+
AND evidence.evidence_binding_digest = p_evidence_binding_digest
|
|
846
|
+
) THEN
|
|
847
|
+
-- idempotent_reconciliation: retry after an ambiguous COMMIT.
|
|
848
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
849
|
+
ELSE
|
|
850
|
+
RETURN QUERY SELECT FALSE, NULL::TEXT;
|
|
851
|
+
END IF;
|
|
852
|
+
END
|
|
853
|
+
$fn$;
|
|
854
|
+
|
|
855
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.read_attempt(
|
|
856
|
+
p_tenant_id TEXT,
|
|
857
|
+
p_provider_id TEXT,
|
|
858
|
+
p_provider_account_id TEXT,
|
|
859
|
+
p_environment TEXT,
|
|
860
|
+
p_attempt_id TEXT,
|
|
861
|
+
p_request_digest TEXT
|
|
862
|
+
)
|
|
863
|
+
RETURNS TABLE(
|
|
864
|
+
tenant_id TEXT,
|
|
865
|
+
provider_id TEXT,
|
|
866
|
+
provider_account_id TEXT,
|
|
867
|
+
environment TEXT,
|
|
868
|
+
attempt_id TEXT,
|
|
869
|
+
operation_digest TEXT,
|
|
870
|
+
request_digest TEXT,
|
|
871
|
+
action_digest TEXT,
|
|
872
|
+
config_digest TEXT,
|
|
873
|
+
attempt_digest TEXT,
|
|
874
|
+
state TEXT,
|
|
875
|
+
evidence_digest TEXT,
|
|
876
|
+
owner_generation BIGINT,
|
|
877
|
+
last_heartbeat_at TEXT,
|
|
878
|
+
lease_expires_at TEXT,
|
|
879
|
+
lease_stale BOOLEAN
|
|
880
|
+
)
|
|
881
|
+
LANGUAGE plpgsql
|
|
882
|
+
VOLATILE
|
|
883
|
+
SECURITY DEFINER
|
|
884
|
+
SET search_path = ''
|
|
885
|
+
AS $fn$
|
|
886
|
+
BEGIN
|
|
887
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, NULL);
|
|
888
|
+
RETURN QUERY SELECT
|
|
889
|
+
attempts.tenant_id,
|
|
890
|
+
attempts.provider_id,
|
|
891
|
+
attempts.provider_account_id,
|
|
892
|
+
attempts.environment,
|
|
893
|
+
attempts.attempt_id,
|
|
894
|
+
attempts.operation_digest,
|
|
895
|
+
attempts.request_digest,
|
|
896
|
+
attempts.action_digest,
|
|
897
|
+
attempts.config_digest,
|
|
898
|
+
attempts.attempt_digest,
|
|
899
|
+
attempts.state,
|
|
900
|
+
attempts.evidence_digest,
|
|
901
|
+
attempts.owner_generation,
|
|
902
|
+
pg_catalog.to_char(
|
|
903
|
+
attempts.last_heartbeat_at AT TIME ZONE 'UTC',
|
|
904
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
905
|
+
),
|
|
906
|
+
pg_catalog.to_char(
|
|
907
|
+
attempts.lease_expires_at AT TIME ZONE 'UTC',
|
|
908
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
909
|
+
),
|
|
910
|
+
attempts.lease_expires_at <= pg_catalog.clock_timestamp()
|
|
911
|
+
FROM proposal_to_effect_private.consequence_attempts AS attempts
|
|
912
|
+
WHERE attempts.tenant_id = p_tenant_id
|
|
913
|
+
AND attempts.provider_id = p_provider_id
|
|
914
|
+
AND attempts.provider_account_id = p_provider_account_id
|
|
915
|
+
AND attempts.environment = p_environment
|
|
916
|
+
AND attempts.attempt_id = p_attempt_id
|
|
917
|
+
AND attempts.request_digest = p_request_digest;
|
|
918
|
+
END
|
|
919
|
+
$fn$;
|
|
920
|
+
|
|
921
|
+
CREATE OR REPLACE FUNCTION proposal_to_effect_private.recover_attempt(
|
|
922
|
+
p_tenant_id TEXT,
|
|
923
|
+
p_provider_id TEXT,
|
|
924
|
+
p_provider_account_id TEXT,
|
|
925
|
+
p_environment TEXT,
|
|
926
|
+
p_attempt_id TEXT,
|
|
927
|
+
p_request_digest TEXT,
|
|
928
|
+
p_attempt_digest TEXT,
|
|
929
|
+
p_owner_generation BIGINT,
|
|
930
|
+
p_expected_state TEXT,
|
|
931
|
+
p_expected_lease_expires_at TIMESTAMPTZ,
|
|
932
|
+
p_next_owner_digest TEXT,
|
|
933
|
+
p_lease_seconds INTEGER
|
|
934
|
+
)
|
|
935
|
+
RETURNS TABLE(applied BOOLEAN, reason TEXT)
|
|
936
|
+
LANGUAGE plpgsql
|
|
937
|
+
SECURITY DEFINER
|
|
938
|
+
SET search_path = ''
|
|
939
|
+
AS $fn$
|
|
940
|
+
DECLARE
|
|
941
|
+
v_count BIGINT;
|
|
942
|
+
v_now TIMESTAMPTZ;
|
|
943
|
+
BEGIN
|
|
944
|
+
PERFORM proposal_to_effect_private.assert_tenant_principal(p_tenant_id, TRUE);
|
|
945
|
+
IF p_lease_seconds < 1 OR p_lease_seconds > 300 THEN
|
|
946
|
+
RAISE EXCEPTION 'PTE_LEASE_DURATION_REFUSED'
|
|
947
|
+
USING ERRCODE = '22023';
|
|
948
|
+
END IF;
|
|
949
|
+
v_now := pg_catalog.clock_timestamp();
|
|
950
|
+
UPDATE proposal_to_effect_private.consequence_attempts
|
|
951
|
+
SET owner_digest = p_next_owner_digest,
|
|
952
|
+
owner_generation = owner_generation + 1,
|
|
953
|
+
state = CASE
|
|
954
|
+
WHEN state = 'RESERVED' THEN 'RESERVED'
|
|
955
|
+
ELSE 'INDETERMINATE'
|
|
956
|
+
END,
|
|
957
|
+
last_heartbeat_at = v_now,
|
|
958
|
+
lease_expires_at = v_now + pg_catalog.make_interval(secs => p_lease_seconds),
|
|
959
|
+
updated_at = v_now
|
|
960
|
+
WHERE tenant_id = p_tenant_id
|
|
961
|
+
AND provider_id = p_provider_id
|
|
962
|
+
AND provider_account_id = p_provider_account_id
|
|
963
|
+
AND environment = p_environment
|
|
964
|
+
AND attempt_id = p_attempt_id
|
|
965
|
+
AND request_digest = p_request_digest
|
|
966
|
+
AND attempt_digest = p_attempt_digest
|
|
967
|
+
AND owner_generation = p_owner_generation
|
|
968
|
+
AND state = p_expected_state
|
|
969
|
+
AND state IN ('RESERVED', 'INVOKING', 'INDETERMINATE')
|
|
970
|
+
AND lease_expires_at = p_expected_lease_expires_at
|
|
971
|
+
AND lease_expires_at <= pg_catalog.clock_timestamp();
|
|
972
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
973
|
+
IF v_count = 1 THEN
|
|
974
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
975
|
+
ELSIF EXISTS (
|
|
976
|
+
SELECT 1
|
|
977
|
+
FROM proposal_to_effect_private.consequence_attempts
|
|
978
|
+
WHERE tenant_id = p_tenant_id
|
|
979
|
+
AND provider_id = p_provider_id
|
|
980
|
+
AND provider_account_id = p_provider_account_id
|
|
981
|
+
AND environment = p_environment
|
|
982
|
+
AND attempt_id = p_attempt_id
|
|
983
|
+
AND request_digest = p_request_digest
|
|
984
|
+
AND attempt_digest = p_attempt_digest
|
|
985
|
+
AND owner_generation = p_owner_generation + 1
|
|
986
|
+
AND owner_digest = p_next_owner_digest
|
|
987
|
+
AND state = CASE
|
|
988
|
+
WHEN p_expected_state = 'RESERVED' THEN 'RESERVED'
|
|
989
|
+
ELSE 'INDETERMINATE'
|
|
990
|
+
END
|
|
991
|
+
) THEN
|
|
992
|
+
-- idempotent_recovery: retry after an ambiguous COMMIT.
|
|
993
|
+
RETURN QUERY SELECT TRUE, NULL::TEXT;
|
|
994
|
+
ELSIF EXISTS (
|
|
995
|
+
SELECT 1
|
|
996
|
+
FROM proposal_to_effect_private.consequence_attempts
|
|
997
|
+
WHERE tenant_id = p_tenant_id
|
|
998
|
+
AND provider_id = p_provider_id
|
|
999
|
+
AND provider_account_id = p_provider_account_id
|
|
1000
|
+
AND environment = p_environment
|
|
1001
|
+
AND attempt_id = p_attempt_id
|
|
1002
|
+
AND request_digest = p_request_digest
|
|
1003
|
+
AND attempt_digest = p_attempt_digest
|
|
1004
|
+
AND owner_generation = p_owner_generation
|
|
1005
|
+
AND state = p_expected_state
|
|
1006
|
+
AND lease_expires_at = p_expected_lease_expires_at
|
|
1007
|
+
AND lease_expires_at > pg_catalog.clock_timestamp()
|
|
1008
|
+
) THEN
|
|
1009
|
+
RETURN QUERY SELECT FALSE, 'attempt_not_stale'::TEXT;
|
|
1010
|
+
ELSE
|
|
1011
|
+
RETURN QUERY SELECT FALSE, 'recovery_conflict'::TEXT;
|
|
1012
|
+
END IF;
|
|
1013
|
+
END
|
|
1014
|
+
$fn$;
|
|
1015
|
+
|
|
1016
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA proposal_to_effect_private FROM PUBLIC;
|
|
1017
|
+
|
|
1018
|
+
GRANT USAGE ON SCHEMA proposal_to_effect_private
|
|
1019
|
+
TO proposal_to_effect_executor, proposal_to_effect_recovery;
|
|
1020
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.reserve_attempt(
|
|
1021
|
+
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
|
|
1022
|
+
) TO proposal_to_effect_executor;
|
|
1023
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.transition_attempt(
|
|
1024
|
+
TEXT, TEXT, TEXT, TEXT, TEXT, INTEGER
|
|
1025
|
+
) TO proposal_to_effect_executor;
|
|
1026
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.heartbeat_attempt(
|
|
1027
|
+
TEXT, TEXT, TEXT, INTEGER
|
|
1028
|
+
) TO proposal_to_effect_executor;
|
|
1029
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.reconcile_attempt(
|
|
1030
|
+
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT,
|
|
1031
|
+
TEXT, TEXT, TEXT, TEXT, TIMESTAMPTZ, TEXT, TEXT, TEXT
|
|
1032
|
+
) TO proposal_to_effect_executor;
|
|
1033
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.read_attempt(
|
|
1034
|
+
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT
|
|
1035
|
+
) TO proposal_to_effect_executor, proposal_to_effect_recovery;
|
|
1036
|
+
GRANT EXECUTE ON FUNCTION proposal_to_effect_private.recover_attempt(
|
|
1037
|
+
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BIGINT, TEXT, TIMESTAMPTZ,
|
|
1038
|
+
TEXT, INTEGER
|
|
1039
|
+
) TO proposal_to_effect_recovery;
|
|
1040
|
+
`;
|
|
1041
|
+
|
|
1042
|
+
export const PROPOSAL_TO_EFFECT_POSTGRES_SQL = Object.freeze({
|
|
1043
|
+
reserve: `SELECT * FROM proposal_to_effect_private.reserve_attempt(
|
|
1044
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
|
1045
|
+
)`,
|
|
1046
|
+
transition: `SELECT * FROM proposal_to_effect_private.transition_attempt(
|
|
1047
|
+
$1, $2, $3, $4, $5, $6
|
|
1048
|
+
)`,
|
|
1049
|
+
heartbeat: `SELECT * FROM proposal_to_effect_private.heartbeat_attempt(
|
|
1050
|
+
$1, $2, $3, $4
|
|
1051
|
+
)`,
|
|
1052
|
+
reconcile: `SELECT * FROM proposal_to_effect_private.reconcile_attempt(
|
|
1053
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
1054
|
+
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19
|
|
1055
|
+
)`,
|
|
1056
|
+
lookup: `SELECT * FROM proposal_to_effect_private.lookup_attempt(
|
|
1057
|
+
$1, $2, $3, $4, $5
|
|
1058
|
+
)`,
|
|
1059
|
+
read: `SELECT * FROM proposal_to_effect_private.read_attempt(
|
|
1060
|
+
$1, $2, $3, $4, $5, $6
|
|
1061
|
+
)`,
|
|
1062
|
+
recover: `SELECT * FROM proposal_to_effect_private.recover_attempt(
|
|
1063
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
|
1064
|
+
)`,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
function isRecord(value: unknown): value is QueryRow {
|
|
1068
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function hasExactKeys(value: QueryRow, expected: readonly string[]): boolean {
|
|
1072
|
+
const actual = Reflect.ownKeys(value);
|
|
1073
|
+
if (actual.some((key) => typeof key !== 'string') || actual.length !== expected.length) {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
const names = new Set(actual as string[]);
|
|
1077
|
+
return expected.every((key) => names.has(key));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function assertIdentifier(value: unknown, name: string): asserts value is string {
|
|
1081
|
+
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
|
1082
|
+
throw new TypeError(`${name} is invalid`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function assertDigest(value: unknown, name: string): asserts value is AebDigest {
|
|
1087
|
+
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
|
1088
|
+
throw new TypeError(`${name} is invalid`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function assertInstant(value: unknown, name: string): asserts value is string {
|
|
1093
|
+
if (typeof value !== 'string'
|
|
1094
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
1095
|
+
|| !Number.isFinite(Date.parse(value))
|
|
1096
|
+
|| new Date(Date.parse(value)).toISOString() !== value) {
|
|
1097
|
+
throw new TypeError(`${name} is invalid`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function assertOwner(value: unknown): asserts value is ConsequenceAttemptOwnerHandle {
|
|
1102
|
+
if (typeof value !== 'string' || !OWNER_PATTERN.test(value)) {
|
|
1103
|
+
throw new TypeError('consequence attempt owner is invalid');
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function assertBinding(value: unknown): asserts value is ConsequenceAttemptBinding {
|
|
1108
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
1109
|
+
'tenant_id',
|
|
1110
|
+
'provider_id',
|
|
1111
|
+
'provider_account_id',
|
|
1112
|
+
'environment',
|
|
1113
|
+
'attempt_id',
|
|
1114
|
+
'request_digest',
|
|
1115
|
+
])) {
|
|
1116
|
+
throw new TypeError('consequence attempt binding is invalid');
|
|
1117
|
+
}
|
|
1118
|
+
assertIdentifier(value.tenant_id, 'tenant_id');
|
|
1119
|
+
assertIdentifier(value.provider_id, 'provider_id');
|
|
1120
|
+
assertIdentifier(value.provider_account_id, 'provider_account_id');
|
|
1121
|
+
assertIdentifier(value.environment, 'environment');
|
|
1122
|
+
assertIdentifier(value.attempt_id, 'attempt_id');
|
|
1123
|
+
assertDigest(value.request_digest, 'request_digest');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function assertAttemptReference(
|
|
1127
|
+
value: unknown,
|
|
1128
|
+
): asserts value is ProposalToEffectPostgresAttemptReference {
|
|
1129
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
1130
|
+
'tenant_id',
|
|
1131
|
+
'provider_id',
|
|
1132
|
+
'provider_account_id',
|
|
1133
|
+
'environment',
|
|
1134
|
+
'attempt_id',
|
|
1135
|
+
'request_digest',
|
|
1136
|
+
])) {
|
|
1137
|
+
throw new TypeError('consequence attempt reference is invalid');
|
|
1138
|
+
}
|
|
1139
|
+
assertBinding(value);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function assertAttemptLookup(
|
|
1143
|
+
value: unknown,
|
|
1144
|
+
): asserts value is ProposalToEffectPostgresAttemptLookup {
|
|
1145
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
1146
|
+
'tenant_id',
|
|
1147
|
+
'provider_id',
|
|
1148
|
+
'provider_account_id',
|
|
1149
|
+
'environment',
|
|
1150
|
+
'request_digest',
|
|
1151
|
+
])) {
|
|
1152
|
+
throw new TypeError('consequence attempt lookup is invalid');
|
|
1153
|
+
}
|
|
1154
|
+
assertIdentifier(value.tenant_id, 'lookup tenant_id');
|
|
1155
|
+
assertIdentifier(value.provider_id, 'lookup provider_id');
|
|
1156
|
+
assertIdentifier(value.provider_account_id, 'lookup provider_account_id');
|
|
1157
|
+
assertIdentifier(value.environment, 'lookup environment');
|
|
1158
|
+
assertDigest(value.request_digest, 'lookup request_digest');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function assertResolvedDigests(value: unknown): asserts value is ProposalToEffectAttemptDigests {
|
|
1162
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
1163
|
+
'operation_digest',
|
|
1164
|
+
'action_digest',
|
|
1165
|
+
'config_digest',
|
|
1166
|
+
])) {
|
|
1167
|
+
throw new TypeError('resolved consequence attempt digests are invalid');
|
|
1168
|
+
}
|
|
1169
|
+
assertDigest(value.operation_digest, 'operation_digest');
|
|
1170
|
+
assertDigest(value.action_digest, 'action_digest');
|
|
1171
|
+
assertDigest(value.config_digest, 'config_digest');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function cloneBinding(binding: ConsequenceAttemptBinding): ConsequenceAttemptBinding {
|
|
1175
|
+
return {
|
|
1176
|
+
tenant_id: binding.tenant_id,
|
|
1177
|
+
provider_id: binding.provider_id,
|
|
1178
|
+
provider_account_id: binding.provider_account_id,
|
|
1179
|
+
environment: binding.environment,
|
|
1180
|
+
attempt_id: binding.attempt_id,
|
|
1181
|
+
request_digest: binding.request_digest,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function sha256(domain: string, value: QueryRow): AebDigest {
|
|
1186
|
+
return `sha256:${crypto.createHash('sha256')
|
|
1187
|
+
.update(domain)
|
|
1188
|
+
.update(JSON.stringify(value))
|
|
1189
|
+
.digest('hex')}` as AebDigest;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
export function proposalToEffectAttemptDigest(
|
|
1193
|
+
binding: ConsequenceAttemptBinding,
|
|
1194
|
+
digests: ProposalToEffectAttemptDigests,
|
|
1195
|
+
): AebDigest {
|
|
1196
|
+
assertBinding(binding);
|
|
1197
|
+
assertResolvedDigests(digests);
|
|
1198
|
+
return sha256(ATTEMPT_DOMAIN, {
|
|
1199
|
+
tenant_id: binding.tenant_id,
|
|
1200
|
+
provider_id: binding.provider_id,
|
|
1201
|
+
provider_account_id: binding.provider_account_id,
|
|
1202
|
+
environment: binding.environment,
|
|
1203
|
+
attempt_id: binding.attempt_id,
|
|
1204
|
+
operation_digest: digests.operation_digest,
|
|
1205
|
+
request_digest: binding.request_digest,
|
|
1206
|
+
action_digest: digests.action_digest,
|
|
1207
|
+
config_digest: digests.config_digest,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function evidenceBindingDigest(
|
|
1212
|
+
attemptDigest: AebDigest,
|
|
1213
|
+
evidence: AuthenticatedProviderEvidenceBinding,
|
|
1214
|
+
): AebDigest {
|
|
1215
|
+
return sha256(EVIDENCE_DOMAIN, {
|
|
1216
|
+
attempt_digest: attemptDigest,
|
|
1217
|
+
operation_id: evidence.operation_id,
|
|
1218
|
+
caid: evidence.caid,
|
|
1219
|
+
action_digest: evidence.action_digest,
|
|
1220
|
+
evidence_id: evidence.evidence_id,
|
|
1221
|
+
observed_at: evidence.observed_at,
|
|
1222
|
+
outcome: evidence.outcome,
|
|
1223
|
+
evidence_digest: evidence.evidence_digest,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function parseOwnerGeneration(value: unknown): number {
|
|
1228
|
+
const parsed = typeof value === 'number'
|
|
1229
|
+
? value
|
|
1230
|
+
: typeof value === 'string' && /^(?:0|[1-9][0-9]*)$/.test(value)
|
|
1231
|
+
? Number(value)
|
|
1232
|
+
: NaN;
|
|
1233
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
1234
|
+
throw new Error('malformed Postgres result: owner_generation');
|
|
1235
|
+
}
|
|
1236
|
+
return parsed;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function assertResultEnvelope(
|
|
1240
|
+
result: ProposalToEffectPostgresQueryResult,
|
|
1241
|
+
operation: string,
|
|
1242
|
+
): void {
|
|
1243
|
+
if (!result || !Array.isArray(result.rows)
|
|
1244
|
+
|| (result.rowCount !== null
|
|
1245
|
+
&& (!Number.isSafeInteger(result.rowCount) || result.rowCount < 0))) {
|
|
1246
|
+
throw new Error(`malformed Postgres result: ${operation}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function parseApplied(
|
|
1251
|
+
result: ProposalToEffectPostgresQueryResult,
|
|
1252
|
+
operation: string,
|
|
1253
|
+
): { applied: boolean; reason: string | null } {
|
|
1254
|
+
assertResultEnvelope(result, operation);
|
|
1255
|
+
if (result.rowCount !== 1 || result.rows.length !== 1 || !isRecord(result.rows[0])
|
|
1256
|
+
|| !hasExactKeys(result.rows[0], ['applied', 'reason'])
|
|
1257
|
+
|| typeof result.rows[0].applied !== 'boolean'
|
|
1258
|
+
|| (result.rows[0].reason !== null && typeof result.rows[0].reason !== 'string')
|
|
1259
|
+
|| (result.rows[0].applied && result.rows[0].reason !== null)) {
|
|
1260
|
+
throw new Error(`malformed Postgres result: ${operation}`);
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
applied: result.rows[0].applied,
|
|
1264
|
+
reason: result.rows[0].reason,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function parseSnapshot(
|
|
1269
|
+
result: ProposalToEffectPostgresQueryResult,
|
|
1270
|
+
input: ProposalToEffectPostgresAttemptReference,
|
|
1271
|
+
): (ProposalToEffectPostgresRecoveryAuthorization & { owner_generation: number }) | null {
|
|
1272
|
+
assertResultEnvelope(result, 'read');
|
|
1273
|
+
if (result.rowCount === 0 && result.rows.length === 0) return null;
|
|
1274
|
+
const row = result.rows[0];
|
|
1275
|
+
if (result.rowCount !== 1 || result.rows.length !== 1 || !isRecord(row)
|
|
1276
|
+
|| !hasExactKeys(row, [
|
|
1277
|
+
'tenant_id',
|
|
1278
|
+
'provider_id',
|
|
1279
|
+
'provider_account_id',
|
|
1280
|
+
'environment',
|
|
1281
|
+
'attempt_id',
|
|
1282
|
+
'operation_digest',
|
|
1283
|
+
'request_digest',
|
|
1284
|
+
'action_digest',
|
|
1285
|
+
'config_digest',
|
|
1286
|
+
'attempt_digest',
|
|
1287
|
+
'state',
|
|
1288
|
+
'evidence_digest',
|
|
1289
|
+
'owner_generation',
|
|
1290
|
+
'last_heartbeat_at',
|
|
1291
|
+
'lease_expires_at',
|
|
1292
|
+
'lease_stale',
|
|
1293
|
+
])) {
|
|
1294
|
+
throw new Error('malformed Postgres result: read');
|
|
1295
|
+
}
|
|
1296
|
+
assertIdentifier(row.tenant_id, 'read tenant_id');
|
|
1297
|
+
assertIdentifier(row.provider_id, 'read provider_id');
|
|
1298
|
+
assertIdentifier(row.provider_account_id, 'read provider_account_id');
|
|
1299
|
+
assertIdentifier(row.environment, 'read environment');
|
|
1300
|
+
assertIdentifier(row.attempt_id, 'read attempt_id');
|
|
1301
|
+
assertDigest(row.operation_digest, 'read operation_digest');
|
|
1302
|
+
assertDigest(row.request_digest, 'read request_digest');
|
|
1303
|
+
assertDigest(row.action_digest, 'read action_digest');
|
|
1304
|
+
assertDigest(row.config_digest, 'read config_digest');
|
|
1305
|
+
assertDigest(row.attempt_digest, 'read attempt_digest');
|
|
1306
|
+
if (typeof row.state !== 'string' || !STATES.has(row.state as ConsequenceAttemptState)) {
|
|
1307
|
+
throw new Error('malformed Postgres result: read state');
|
|
1308
|
+
}
|
|
1309
|
+
if (row.evidence_digest !== null) {
|
|
1310
|
+
assertDigest(row.evidence_digest, 'read evidence_digest');
|
|
1311
|
+
}
|
|
1312
|
+
assertInstant(row.last_heartbeat_at, 'read last_heartbeat_at');
|
|
1313
|
+
assertInstant(row.lease_expires_at, 'read lease_expires_at');
|
|
1314
|
+
if (typeof row.lease_stale !== 'boolean'
|
|
1315
|
+
|| Date.parse(row.last_heartbeat_at) >= Date.parse(row.lease_expires_at)) {
|
|
1316
|
+
throw new Error('malformed Postgres result: read lease');
|
|
1317
|
+
}
|
|
1318
|
+
if (row.tenant_id !== input.tenant_id
|
|
1319
|
+
|| row.provider_id !== input.provider_id
|
|
1320
|
+
|| row.provider_account_id !== input.provider_account_id
|
|
1321
|
+
|| row.environment !== input.environment
|
|
1322
|
+
|| row.attempt_id !== input.attempt_id
|
|
1323
|
+
|| row.request_digest !== input.request_digest) {
|
|
1324
|
+
throw new Error('malformed Postgres result: read binding');
|
|
1325
|
+
}
|
|
1326
|
+
return {
|
|
1327
|
+
tenant_id: row.tenant_id,
|
|
1328
|
+
provider_id: row.provider_id,
|
|
1329
|
+
provider_account_id: row.provider_account_id,
|
|
1330
|
+
environment: row.environment,
|
|
1331
|
+
attempt_id: row.attempt_id,
|
|
1332
|
+
operation_digest: row.operation_digest,
|
|
1333
|
+
request_digest: row.request_digest,
|
|
1334
|
+
action_digest: row.action_digest,
|
|
1335
|
+
config_digest: row.config_digest,
|
|
1336
|
+
attempt_digest: row.attempt_digest,
|
|
1337
|
+
state: row.state as ConsequenceAttemptState,
|
|
1338
|
+
evidence_digest: row.evidence_digest,
|
|
1339
|
+
owner_generation: parseOwnerGeneration(row.owner_generation),
|
|
1340
|
+
last_heartbeat_at: row.last_heartbeat_at,
|
|
1341
|
+
lease_expires_at: row.lease_expires_at,
|
|
1342
|
+
lease_stale: row.lease_stale,
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function parseLookup(
|
|
1347
|
+
result: ProposalToEffectPostgresQueryResult,
|
|
1348
|
+
input: ProposalToEffectPostgresAttemptLookup,
|
|
1349
|
+
): ProposalToEffectPostgresAttemptReference | null {
|
|
1350
|
+
assertResultEnvelope(result, 'lookup');
|
|
1351
|
+
if (result.rowCount === 0 && result.rows.length === 0) return null;
|
|
1352
|
+
const row = result.rows[0];
|
|
1353
|
+
if (result.rowCount !== 1 || result.rows.length !== 1 || !isRecord(row)
|
|
1354
|
+
|| !hasExactKeys(row, [
|
|
1355
|
+
'tenant_id',
|
|
1356
|
+
'provider_id',
|
|
1357
|
+
'provider_account_id',
|
|
1358
|
+
'environment',
|
|
1359
|
+
'attempt_id',
|
|
1360
|
+
'request_digest',
|
|
1361
|
+
])) {
|
|
1362
|
+
throw new Error('malformed Postgres result: lookup');
|
|
1363
|
+
}
|
|
1364
|
+
assertIdentifier(row.tenant_id, 'lookup tenant_id');
|
|
1365
|
+
assertIdentifier(row.provider_id, 'lookup provider_id');
|
|
1366
|
+
assertIdentifier(row.provider_account_id, 'lookup provider_account_id');
|
|
1367
|
+
assertIdentifier(row.environment, 'lookup environment');
|
|
1368
|
+
assertIdentifier(row.attempt_id, 'lookup attempt_id');
|
|
1369
|
+
assertDigest(row.request_digest, 'lookup request_digest');
|
|
1370
|
+
if (row.tenant_id !== input.tenant_id
|
|
1371
|
+
|| row.provider_id !== input.provider_id
|
|
1372
|
+
|| row.provider_account_id !== input.provider_account_id
|
|
1373
|
+
|| row.environment !== input.environment
|
|
1374
|
+
|| row.request_digest !== input.request_digest) {
|
|
1375
|
+
throw new Error('malformed Postgres result: lookup binding');
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
tenant_id: row.tenant_id,
|
|
1379
|
+
provider_id: row.provider_id,
|
|
1380
|
+
provider_account_id: row.provider_account_id,
|
|
1381
|
+
environment: row.environment,
|
|
1382
|
+
attempt_id: row.attempt_id,
|
|
1383
|
+
request_digest: row.request_digest,
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function transitionAllowed(expected: string, next: string): boolean {
|
|
1388
|
+
return (expected === 'RESERVED' && next === 'INVOKING')
|
|
1389
|
+
|| (expected === 'INVOKING' && next === 'INDETERMINATE')
|
|
1390
|
+
|| (expected === 'INDETERMINATE'
|
|
1391
|
+
&& (next === 'COMMITTED' || next === 'RELEASED' || next === 'ESCALATED'));
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function terminalStateFor(
|
|
1395
|
+
outcome: AuthenticatedProviderEvidenceBinding['outcome'],
|
|
1396
|
+
): 'COMMITTED' | 'RELEASED' | 'ESCALATED' {
|
|
1397
|
+
return outcome === 'COMMITTED'
|
|
1398
|
+
? 'COMMITTED'
|
|
1399
|
+
: outcome === 'NOT_COMMITTED'
|
|
1400
|
+
? 'RELEASED'
|
|
1401
|
+
: 'ESCALATED';
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
class AmbiguousPostgresCommitError extends Error {
|
|
1405
|
+
constructor(operation: string, cause: unknown) {
|
|
1406
|
+
super(`proposal-to-effect ${operation} COMMIT acknowledgement is ambiguous`, {
|
|
1407
|
+
cause,
|
|
1408
|
+
});
|
|
1409
|
+
this.name = 'AmbiguousPostgresCommitError';
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Build the owner-fenced store consumed by createProposalToEffect(), plus
|
|
1415
|
+
* operational read/recovery methods for restart-safe saga repair.
|
|
1416
|
+
*/
|
|
1417
|
+
export function createProposalToEffectPostgresStore(
|
|
1418
|
+
options: CreateProposalToEffectPostgresStoreOptions,
|
|
1419
|
+
): ProposalToEffectPostgresStore {
|
|
1420
|
+
if (!isRecord(options)
|
|
1421
|
+
|| !options.pool
|
|
1422
|
+
|| typeof options.pool.connect !== 'function'
|
|
1423
|
+
|| !options.recovery_pool
|
|
1424
|
+
|| typeof options.recovery_pool.connect !== 'function'
|
|
1425
|
+
|| options.recovery_pool === options.pool
|
|
1426
|
+
|| !(options.owner_hmac_sha256_key instanceof Uint8Array)
|
|
1427
|
+
|| options.owner_hmac_sha256_key.byteLength < 32
|
|
1428
|
+
|| typeof options.resolve_binding_digests !== 'function'
|
|
1429
|
+
|| typeof options.authorize_recovery !== 'function'
|
|
1430
|
+
|| (options.random_bytes !== undefined && typeof options.random_bytes !== 'function')
|
|
1431
|
+
|| (options.lease_seconds !== undefined
|
|
1432
|
+
&& (!Number.isSafeInteger(options.lease_seconds)
|
|
1433
|
+
|| options.lease_seconds < 1
|
|
1434
|
+
|| options.lease_seconds > MAX_LEASE_SECONDS))) {
|
|
1435
|
+
throw new TypeError('createProposalToEffectPostgresStore configuration is invalid');
|
|
1436
|
+
}
|
|
1437
|
+
const pool = options.pool;
|
|
1438
|
+
const recoveryPool = options.recovery_pool;
|
|
1439
|
+
const ownerKey = Buffer.from(options.owner_hmac_sha256_key);
|
|
1440
|
+
const randomBytes = options.random_bytes ?? ((size: number) => crypto.randomBytes(size));
|
|
1441
|
+
const leaseSeconds = options.lease_seconds ?? DEFAULT_LEASE_SECONDS;
|
|
1442
|
+
|
|
1443
|
+
async function transaction<T>(
|
|
1444
|
+
selectedPool: ProposalToEffectPostgresPool,
|
|
1445
|
+
readOnly: boolean,
|
|
1446
|
+
operation: string,
|
|
1447
|
+
work: (client: ProposalToEffectPostgresClient) => Promise<T>,
|
|
1448
|
+
): Promise<T> {
|
|
1449
|
+
const client = await selectedPool.connect();
|
|
1450
|
+
if (!client || typeof client.query !== 'function' || typeof client.release !== 'function') {
|
|
1451
|
+
throw new TypeError('proposal-to-effect pg pool returned an invalid client');
|
|
1452
|
+
}
|
|
1453
|
+
let began = false;
|
|
1454
|
+
let discardError: Error | undefined;
|
|
1455
|
+
try {
|
|
1456
|
+
await client.query(readOnly
|
|
1457
|
+
? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
|
|
1458
|
+
: 'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE');
|
|
1459
|
+
began = true;
|
|
1460
|
+
const value = await work(client);
|
|
1461
|
+
try {
|
|
1462
|
+
await client.query('COMMIT');
|
|
1463
|
+
began = false;
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
began = false;
|
|
1466
|
+
discardError = error instanceof Error
|
|
1467
|
+
? error
|
|
1468
|
+
: new Error('unknown PostgreSQL COMMIT failure');
|
|
1469
|
+
throw new AmbiguousPostgresCommitError(operation, error);
|
|
1470
|
+
}
|
|
1471
|
+
return value;
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
if (began) {
|
|
1474
|
+
try {
|
|
1475
|
+
await client.query('ROLLBACK');
|
|
1476
|
+
} catch (rollbackError) {
|
|
1477
|
+
throw new AggregateError(
|
|
1478
|
+
[error, rollbackError],
|
|
1479
|
+
'proposal-to-effect transaction and rollback both failed',
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
throw error;
|
|
1484
|
+
} finally {
|
|
1485
|
+
client.release(discardError);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function retryAmbiguousCommit<T>(
|
|
1490
|
+
selectedPool: ProposalToEffectPostgresPool,
|
|
1491
|
+
readOnly: boolean,
|
|
1492
|
+
operation: string,
|
|
1493
|
+
work: (client: ProposalToEffectPostgresClient) => Promise<T>,
|
|
1494
|
+
): Promise<T> {
|
|
1495
|
+
try {
|
|
1496
|
+
return await transaction(selectedPool, readOnly, operation, work);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
if (!(error instanceof AmbiguousPostgresCommitError)) throw error;
|
|
1499
|
+
return transaction(selectedPool, readOnly, operation, work);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function issueOwner(): ConsequenceAttemptOwnerHandle {
|
|
1504
|
+
const bytes = randomBytes(OWNER_BYTES);
|
|
1505
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== OWNER_BYTES) {
|
|
1506
|
+
throw new TypeError('proposal-to-effect owner entropy source is invalid');
|
|
1507
|
+
}
|
|
1508
|
+
return (
|
|
1509
|
+
`pto-owner:v1:${Buffer.from(bytes).toString('base64url')}`
|
|
1510
|
+
) as ConsequenceAttemptOwnerHandle;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function ownerDigest(owner: ConsequenceAttemptOwnerHandle): AebDigest {
|
|
1514
|
+
return `sha256:${crypto.createHmac('sha256', ownerKey)
|
|
1515
|
+
.update(OWNER_DOMAIN)
|
|
1516
|
+
.update(owner)
|
|
1517
|
+
.digest('hex')}` as AebDigest;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function resolveDigests(
|
|
1521
|
+
attemptBinding: ConsequenceAttemptBinding,
|
|
1522
|
+
): Promise<ProposalToEffectAttemptDigests> {
|
|
1523
|
+
const resolved = await options.resolve_binding_digests(
|
|
1524
|
+
Object.freeze(cloneBinding(attemptBinding)),
|
|
1525
|
+
);
|
|
1526
|
+
assertResolvedDigests(resolved);
|
|
1527
|
+
return {
|
|
1528
|
+
operation_digest: resolved.operation_digest,
|
|
1529
|
+
action_digest: resolved.action_digest,
|
|
1530
|
+
config_digest: resolved.config_digest,
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
async function readInternal(
|
|
1535
|
+
selectedPool: ProposalToEffectPostgresPool,
|
|
1536
|
+
input: ProposalToEffectPostgresAttemptReference,
|
|
1537
|
+
): Promise<ProposalToEffectPostgresRecoveryAuthorization | null> {
|
|
1538
|
+
return retryAmbiguousCommit(selectedPool, true, 'read', async (client) => parseSnapshot(
|
|
1539
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.read, [
|
|
1540
|
+
input.tenant_id,
|
|
1541
|
+
input.provider_id,
|
|
1542
|
+
input.provider_account_id,
|
|
1543
|
+
input.environment,
|
|
1544
|
+
input.attempt_id,
|
|
1545
|
+
input.request_digest,
|
|
1546
|
+
]),
|
|
1547
|
+
input,
|
|
1548
|
+
));
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const store: ProposalToEffectPostgresStore = {
|
|
1552
|
+
durable: true,
|
|
1553
|
+
ownershipFenced: true,
|
|
1554
|
+
compareAndSwap: true,
|
|
1555
|
+
atomicEvidenceBinding: true,
|
|
1556
|
+
|
|
1557
|
+
async reserve(attemptBinding) {
|
|
1558
|
+
assertBinding(attemptBinding);
|
|
1559
|
+
const digests = await resolveDigests(attemptBinding);
|
|
1560
|
+
const attemptDigest = proposalToEffectAttemptDigest(attemptBinding, digests);
|
|
1561
|
+
const owner = issueOwner();
|
|
1562
|
+
const result = await retryAmbiguousCommit(pool, false, 'reserve', async (client) => parseApplied(
|
|
1563
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.reserve, [
|
|
1564
|
+
attemptBinding.tenant_id,
|
|
1565
|
+
attemptBinding.provider_id,
|
|
1566
|
+
attemptBinding.provider_account_id,
|
|
1567
|
+
attemptBinding.environment,
|
|
1568
|
+
attemptBinding.attempt_id,
|
|
1569
|
+
digests.operation_digest,
|
|
1570
|
+
attemptBinding.request_digest,
|
|
1571
|
+
digests.action_digest,
|
|
1572
|
+
digests.config_digest,
|
|
1573
|
+
attemptDigest,
|
|
1574
|
+
ownerDigest(owner),
|
|
1575
|
+
leaseSeconds,
|
|
1576
|
+
]),
|
|
1577
|
+
'reserve',
|
|
1578
|
+
));
|
|
1579
|
+
if (!result.applied) {
|
|
1580
|
+
return {
|
|
1581
|
+
reserved: false,
|
|
1582
|
+
reason: result.reason === 'attempt_exists' || result.reason === 'binding_conflict'
|
|
1583
|
+
? result.reason
|
|
1584
|
+
: 'attempt_conflict',
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
return { reserved: true, owner };
|
|
1588
|
+
},
|
|
1589
|
+
|
|
1590
|
+
async transition(input) {
|
|
1591
|
+
if (!isRecord(input) || !hasExactKeys(input, [
|
|
1592
|
+
'tenant_id',
|
|
1593
|
+
'attempt_id',
|
|
1594
|
+
'owner',
|
|
1595
|
+
'expected_state',
|
|
1596
|
+
'next_state',
|
|
1597
|
+
])) {
|
|
1598
|
+
throw new TypeError('consequence attempt transition is invalid');
|
|
1599
|
+
}
|
|
1600
|
+
assertIdentifier(input.tenant_id, 'transition tenant_id');
|
|
1601
|
+
assertIdentifier(input.attempt_id, 'transition attempt_id');
|
|
1602
|
+
assertOwner(input.owner);
|
|
1603
|
+
if (typeof input.expected_state !== 'string'
|
|
1604
|
+
|| typeof input.next_state !== 'string'
|
|
1605
|
+
|| !transitionAllowed(input.expected_state, input.next_state)) {
|
|
1606
|
+
throw new TypeError('consequence attempt state transition is invalid');
|
|
1607
|
+
}
|
|
1608
|
+
const result = await retryAmbiguousCommit(pool, false, 'transition', async (client) => parseApplied(
|
|
1609
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.transition, [
|
|
1610
|
+
input.tenant_id,
|
|
1611
|
+
input.attempt_id,
|
|
1612
|
+
ownerDigest(input.owner),
|
|
1613
|
+
input.expected_state,
|
|
1614
|
+
input.next_state,
|
|
1615
|
+
leaseSeconds,
|
|
1616
|
+
]),
|
|
1617
|
+
'transition',
|
|
1618
|
+
));
|
|
1619
|
+
return result.applied;
|
|
1620
|
+
},
|
|
1621
|
+
|
|
1622
|
+
async heartbeat(input) {
|
|
1623
|
+
if (!isRecord(input) || !hasExactKeys(input, [
|
|
1624
|
+
'tenant_id',
|
|
1625
|
+
'attempt_id',
|
|
1626
|
+
'owner',
|
|
1627
|
+
])) {
|
|
1628
|
+
throw new TypeError('consequence attempt heartbeat is invalid');
|
|
1629
|
+
}
|
|
1630
|
+
assertIdentifier(input.tenant_id, 'heartbeat tenant_id');
|
|
1631
|
+
assertIdentifier(input.attempt_id, 'heartbeat attempt_id');
|
|
1632
|
+
assertOwner(input.owner);
|
|
1633
|
+
const result = await retryAmbiguousCommit(
|
|
1634
|
+
pool,
|
|
1635
|
+
false,
|
|
1636
|
+
'heartbeat',
|
|
1637
|
+
async (client) => parseApplied(
|
|
1638
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.heartbeat, [
|
|
1639
|
+
input.tenant_id,
|
|
1640
|
+
input.attempt_id,
|
|
1641
|
+
ownerDigest(input.owner),
|
|
1642
|
+
leaseSeconds,
|
|
1643
|
+
]),
|
|
1644
|
+
'heartbeat',
|
|
1645
|
+
),
|
|
1646
|
+
);
|
|
1647
|
+
return result.applied;
|
|
1648
|
+
},
|
|
1649
|
+
|
|
1650
|
+
async reconcile(input) {
|
|
1651
|
+
if (!isRecord(input) || !hasExactKeys(input, [
|
|
1652
|
+
'tenant_id',
|
|
1653
|
+
'attempt_id',
|
|
1654
|
+
'owner',
|
|
1655
|
+
'expected_state',
|
|
1656
|
+
'next_state',
|
|
1657
|
+
'evidence',
|
|
1658
|
+
])) {
|
|
1659
|
+
throw new TypeError('consequence attempt reconciliation is invalid');
|
|
1660
|
+
}
|
|
1661
|
+
assertIdentifier(input.tenant_id, 'reconcile tenant_id');
|
|
1662
|
+
assertIdentifier(input.attempt_id, 'reconcile attempt_id');
|
|
1663
|
+
assertOwner(input.owner);
|
|
1664
|
+
if (input.expected_state !== 'INDETERMINATE'
|
|
1665
|
+
|| !isRecord(input.evidence)
|
|
1666
|
+
|| !hasExactKeys(input.evidence, [
|
|
1667
|
+
'tenant_id',
|
|
1668
|
+
'provider_id',
|
|
1669
|
+
'provider_account_id',
|
|
1670
|
+
'environment',
|
|
1671
|
+
'attempt_id',
|
|
1672
|
+
'request_digest',
|
|
1673
|
+
'operation_id',
|
|
1674
|
+
'caid',
|
|
1675
|
+
'action_digest',
|
|
1676
|
+
'evidence_id',
|
|
1677
|
+
'observed_at',
|
|
1678
|
+
'outcome',
|
|
1679
|
+
'evidence_digest',
|
|
1680
|
+
])) {
|
|
1681
|
+
throw new TypeError('consequence attempt evidence binding is invalid');
|
|
1682
|
+
}
|
|
1683
|
+
assertBinding({
|
|
1684
|
+
tenant_id: input.evidence.tenant_id,
|
|
1685
|
+
provider_id: input.evidence.provider_id,
|
|
1686
|
+
provider_account_id: input.evidence.provider_account_id,
|
|
1687
|
+
environment: input.evidence.environment,
|
|
1688
|
+
attempt_id: input.evidence.attempt_id,
|
|
1689
|
+
request_digest: input.evidence.request_digest,
|
|
1690
|
+
});
|
|
1691
|
+
assertIdentifier(input.evidence.operation_id, 'evidence operation_id');
|
|
1692
|
+
assertIdentifier(input.evidence.caid, 'evidence caid');
|
|
1693
|
+
assertDigest(input.evidence.action_digest, 'evidence action_digest');
|
|
1694
|
+
assertIdentifier(input.evidence.evidence_id, 'evidence_id');
|
|
1695
|
+
assertDigest(input.evidence.evidence_digest, 'evidence_digest');
|
|
1696
|
+
if (typeof input.evidence.observed_at !== 'string'
|
|
1697
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(
|
|
1698
|
+
input.evidence.observed_at,
|
|
1699
|
+
)
|
|
1700
|
+
|| !Number.isFinite(Date.parse(input.evidence.observed_at))
|
|
1701
|
+
|| !['COMMITTED', 'NOT_COMMITTED', 'ESCALATED'].includes(input.evidence.outcome)
|
|
1702
|
+
|| input.tenant_id !== input.evidence.tenant_id
|
|
1703
|
+
|| input.attempt_id !== input.evidence.attempt_id
|
|
1704
|
+
|| input.next_state !== terminalStateFor(input.evidence.outcome)) {
|
|
1705
|
+
throw new TypeError('consequence attempt evidence binding is invalid');
|
|
1706
|
+
}
|
|
1707
|
+
const attemptBinding: ConsequenceAttemptBinding = {
|
|
1708
|
+
tenant_id: input.evidence.tenant_id,
|
|
1709
|
+
provider_id: input.evidence.provider_id,
|
|
1710
|
+
provider_account_id: input.evidence.provider_account_id,
|
|
1711
|
+
environment: input.evidence.environment,
|
|
1712
|
+
attempt_id: input.evidence.attempt_id,
|
|
1713
|
+
request_digest: input.evidence.request_digest,
|
|
1714
|
+
};
|
|
1715
|
+
const digests = await resolveDigests(attemptBinding);
|
|
1716
|
+
if (input.evidence.action_digest !== digests.action_digest) {
|
|
1717
|
+
throw new TypeError('consequence attempt evidence binding is invalid');
|
|
1718
|
+
}
|
|
1719
|
+
const attemptDigest = proposalToEffectAttemptDigest(attemptBinding, digests);
|
|
1720
|
+
const providerEvidence = input.evidence as AuthenticatedProviderEvidenceBinding;
|
|
1721
|
+
const result = await retryAmbiguousCommit(pool, false, 'reconcile', async (client) => parseApplied(
|
|
1722
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.reconcile, [
|
|
1723
|
+
attemptBinding.tenant_id,
|
|
1724
|
+
attemptBinding.provider_id,
|
|
1725
|
+
attemptBinding.provider_account_id,
|
|
1726
|
+
attemptBinding.environment,
|
|
1727
|
+
attemptBinding.attempt_id,
|
|
1728
|
+
ownerDigest(input.owner),
|
|
1729
|
+
digests.operation_digest,
|
|
1730
|
+
attemptBinding.request_digest,
|
|
1731
|
+
digests.action_digest,
|
|
1732
|
+
digests.config_digest,
|
|
1733
|
+
attemptDigest,
|
|
1734
|
+
providerEvidence.operation_id,
|
|
1735
|
+
providerEvidence.caid,
|
|
1736
|
+
input.next_state,
|
|
1737
|
+
providerEvidence.evidence_id,
|
|
1738
|
+
providerEvidence.observed_at,
|
|
1739
|
+
providerEvidence.outcome,
|
|
1740
|
+
providerEvidence.evidence_digest,
|
|
1741
|
+
evidenceBindingDigest(attemptDigest, providerEvidence),
|
|
1742
|
+
]),
|
|
1743
|
+
'reconcile',
|
|
1744
|
+
));
|
|
1745
|
+
return result.applied;
|
|
1746
|
+
},
|
|
1747
|
+
|
|
1748
|
+
async lookup(input) {
|
|
1749
|
+
assertAttemptLookup(input);
|
|
1750
|
+
return retryAmbiguousCommit(pool, true, 'lookup', async (client) => parseLookup(
|
|
1751
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.lookup, [
|
|
1752
|
+
input.tenant_id,
|
|
1753
|
+
input.provider_id,
|
|
1754
|
+
input.provider_account_id,
|
|
1755
|
+
input.environment,
|
|
1756
|
+
input.request_digest,
|
|
1757
|
+
]),
|
|
1758
|
+
input,
|
|
1759
|
+
));
|
|
1760
|
+
},
|
|
1761
|
+
|
|
1762
|
+
async read(input) {
|
|
1763
|
+
assertAttemptReference(input);
|
|
1764
|
+
const snapshot = await readInternal(pool, input);
|
|
1765
|
+
if (!snapshot) return null;
|
|
1766
|
+
const { owner_generation: _ownerGeneration, ...operational } = snapshot;
|
|
1767
|
+
return operational;
|
|
1768
|
+
},
|
|
1769
|
+
|
|
1770
|
+
async recover(input) {
|
|
1771
|
+
assertAttemptReference(input);
|
|
1772
|
+
const snapshot = await readInternal(recoveryPool, input);
|
|
1773
|
+
if (!snapshot) return { recovered: false, reason: 'attempt_not_found' };
|
|
1774
|
+
if (TERMINAL_STATES.has(snapshot.state)) {
|
|
1775
|
+
return { recovered: false, reason: 'terminal_state_immutable' };
|
|
1776
|
+
}
|
|
1777
|
+
if (!snapshot.lease_stale) {
|
|
1778
|
+
return { recovered: false, reason: 'attempt_not_stale' };
|
|
1779
|
+
}
|
|
1780
|
+
const authorized = await options.authorize_recovery(Object.freeze({
|
|
1781
|
+
...snapshot,
|
|
1782
|
+
}));
|
|
1783
|
+
if (authorized !== true) {
|
|
1784
|
+
return { recovered: false, reason: 'recovery_not_authorized' };
|
|
1785
|
+
}
|
|
1786
|
+
const owner = issueOwner();
|
|
1787
|
+
const recovered = await retryAmbiguousCommit(
|
|
1788
|
+
recoveryPool,
|
|
1789
|
+
false,
|
|
1790
|
+
'recover',
|
|
1791
|
+
async (client) => parseApplied(
|
|
1792
|
+
await client.query(PROPOSAL_TO_EFFECT_POSTGRES_SQL.recover, [
|
|
1793
|
+
input.tenant_id,
|
|
1794
|
+
input.provider_id,
|
|
1795
|
+
input.provider_account_id,
|
|
1796
|
+
input.environment,
|
|
1797
|
+
input.attempt_id,
|
|
1798
|
+
input.request_digest,
|
|
1799
|
+
snapshot.attempt_digest,
|
|
1800
|
+
snapshot.owner_generation,
|
|
1801
|
+
snapshot.state,
|
|
1802
|
+
snapshot.lease_expires_at,
|
|
1803
|
+
ownerDigest(owner),
|
|
1804
|
+
leaseSeconds,
|
|
1805
|
+
]),
|
|
1806
|
+
'recover',
|
|
1807
|
+
),
|
|
1808
|
+
);
|
|
1809
|
+
if (!recovered.applied) {
|
|
1810
|
+
return {
|
|
1811
|
+
recovered: false,
|
|
1812
|
+
reason: recovered.reason === 'attempt_not_stale'
|
|
1813
|
+
? 'attempt_not_stale'
|
|
1814
|
+
: 'recovery_conflict',
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
return {
|
|
1818
|
+
recovered: true,
|
|
1819
|
+
owner,
|
|
1820
|
+
state: snapshot.state === 'RESERVED' ? 'RESERVED' : 'INDETERMINATE',
|
|
1821
|
+
};
|
|
1822
|
+
},
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
return Object.freeze(store);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
export default {
|
|
1829
|
+
PROPOSAL_TO_EFFECT_POSTGRES_DDL,
|
|
1830
|
+
PROPOSAL_TO_EFFECT_POSTGRES_SQL,
|
|
1831
|
+
proposalToEffectAttemptDigest,
|
|
1832
|
+
createProposalToEffectPostgresStore,
|
|
1833
|
+
};
|